From 344b1a5e3b344c2809371fb25e784e3aaacf6706 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:14:12 +0000 Subject: [PATCH 01/37] x1257: SlotCopySave Support saving and reloading a bunch of info related to a planned slot copy operation without recording the op itself. --- .../sanger/sccp/stan/GraphQLDataFetchers.java | 13 +- .../ac/sanger/sccp/stan/GraphQLMutation.java | 14 +- .../ac/sanger/sccp/stan/GraphQLProvider.java | 2 + .../model/slotcopyrecord/SlotCopyRecord.java | 108 ++++++ .../slotcopyrecord/SlotCopyRecordNote.java | 98 ++++++ .../sccp/stan/repo/SlotCopyRecordRepo.java | 12 + .../sccp/stan/request/SlotCopyRequest.java | 14 + .../sccp/stan/request/SlotCopySave.java | 195 +++++++++++ .../stan/service/SlotCopyRecordService.java | 15 + .../service/SlotCopyRecordServiceImp.java | 259 ++++++++++++++ .../uk/ac/sanger/sccp/utils/BasicUtils.java | 9 + .../resources/db/changelog/changelog-3.30.xml | 68 ++++ .../db/changelog/changelog-master.xml | 1 + src/main/resources/schema.graphqls | 82 +++++ .../TestSaveSlotCopyMutation.java | 86 +++++ .../stan/repo/TestSlotCopyRecordRepo.java | 117 +++++++ .../sccp/stan/repo/TestTagLayoutRepo.java | 11 - .../service/TestSlotCopyRecordService.java | 324 ++++++++++++++++++ .../resources/graphql/reloadslotcopy.graphql | 17 + .../resources/graphql/saveslotcopy.graphql | 25 ++ 20 files changed, 1457 insertions(+), 13 deletions(-) create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/model/slotcopyrecord/SlotCopyRecord.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/model/slotcopyrecord/SlotCopyRecordNote.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/repo/SlotCopyRecordRepo.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/request/SlotCopySave.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordService.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordServiceImp.java create mode 100644 src/main/resources/db/changelog/changelog-3.30.xml create mode 100644 src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSaveSlotCopyMutation.java create mode 100644 src/test/java/uk/ac/sanger/sccp/stan/repo/TestSlotCopyRecordRepo.java create mode 100644 src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyRecordService.java create mode 100644 src/test/resources/graphql/reloadslotcopy.graphql create mode 100644 src/test/resources/graphql/saveslotcopy.graphql diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLDataFetchers.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLDataFetchers.java index 24471783..0dfa1835 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLDataFetchers.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLDataFetchers.java @@ -87,6 +87,7 @@ public class GraphQLDataFetchers extends BaseGraphQLResource { final CommentRepo commentRepo; final AnalyserScanDataService analyserScanDataService; final LabwareNoteService lwNoteService; + final SlotCopyRecordService slotCopyRecordService; @Autowired public GraphQLDataFetchers(ObjectMapper objectMapper, AuthenticationComponent authComp, UserRepo userRepo, @@ -112,7 +113,7 @@ public GraphQLDataFetchers(ObjectMapper objectMapper, AuthenticationComponent au CleanedOutSlotService cleanedOutSlotService, FlagLookupService flagLookupService, MeasurementService measurementService, GraphService graphService, CommentRepo commentRepo, - AnalyserScanDataService analyserScanDataService, LabwareNoteService lwNoteService) { + AnalyserScanDataService analyserScanDataService, LabwareNoteService lwNoteService, SlotCopyRecordService slotCopyRecordService) { super(objectMapper, authComp, userRepo); this.sessionConfig = sessionConfig; this.versionInfo = versionInfo; @@ -163,6 +164,7 @@ public GraphQLDataFetchers(ObjectMapper objectMapper, AuthenticationComponent au this.commentRepo = commentRepo; this.analyserScanDataService = analyserScanDataService; this.lwNoteService = lwNoteService; + this.slotCopyRecordService = slotCopyRecordService; } public DataFetcher getUser() { @@ -522,6 +524,15 @@ public DataFetcher> labwareBioRiskCodes() { }; } + public DataFetcher reloadSlotCopy() { + return dfe -> { + String opname = dfe.getArgument("operationType"); + String workNumber = dfe.getArgument("workNumber"); + String lpNumber = dfe.getArgument("lpNumber"); + return slotCopyRecordService.load(opname, workNumber, lpNumber); + }; + } + public DataFetcher worksSummary() { return dfe -> workSummaryService.loadWorkSummary(); } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java index ce73f99c..65e1cdc0 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java @@ -103,6 +103,7 @@ public class GraphQLMutation extends BaseGraphQLResource { final CleanOutService cleanOutService; final RoiMetricService roiMetricService; final UserAdminService userAdminService; + final SlotCopyRecordService slotCopyRecordService; @Autowired public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authComp, @@ -136,7 +137,7 @@ public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authCo QCLabwareService qcLabwareService, OrientationService orientationService, SSStudyService ssStudyService, ReactivateService reactivateService, LibraryPrepService libraryPrepService, SegmentationService segmentationService, CleanOutService cleanOutService, RoiMetricService roiMetricService, - UserAdminService userAdminService) { + UserAdminService userAdminService, SlotCopyRecordService slotCopyRecordService) { super(objectMapper, authComp, userRepo); this.authService = authService; this.registerService = registerService; @@ -200,6 +201,7 @@ public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authCo this.cleanOutService = cleanOutService; this.roiMetricService = roiMetricService; this.userAdminService = userAdminService; + this.slotCopyRecordService = slotCopyRecordService; } private void logRequest(String name, User user, Object request) { @@ -918,6 +920,16 @@ public DataFetcher recordSampleMetrics() { }; } + public DataFetcher saveSlotCopy() { + return dfe -> { + User user = checkUser(dfe, User.Role.normal); + SlotCopySave request = arg(dfe, "request", SlotCopySave.class); + logRequest("slotCopySave", user, request); + slotCopyRecordService.save(request); + return request; + }; + } + public DataFetcher addUser() { return adminAdd(userAdminService::addNormalUser, "AddUser", "username"); } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java index 067fa96f..80dbfdbb 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java @@ -135,6 +135,7 @@ private RuntimeWiring buildWiring() { .dataFetcher("analyserScanData", graphQLDataFetchers.analyserScanData()) .dataFetcher("runNames", graphQLDataFetchers.runNames()) .dataFetcher("labwareBioRiskCodes", graphQLDataFetchers.labwareBioRiskCodes()) + .dataFetcher("reloadSlotCopy", graphQLDataFetchers.reloadSlotCopy()) .dataFetcher("location", graphQLStore.getLocation()) .dataFetcher("stored", graphQLStore.getStored()) @@ -235,6 +236,7 @@ private RuntimeWiring buildWiring() { .dataFetcher("segmentation", transact(graphQLMutation.segmentation())) .dataFetcher("cleanOut", transact(graphQLMutation.cleanOut())) .dataFetcher("recordSampleMetrics", transact(graphQLMutation.recordSampleMetrics())) + .dataFetcher("saveSlotCopy", transact(graphQLMutation.saveSlotCopy())) .dataFetcher("addUser", transact(graphQLMutation.addUser())) .dataFetcher("setUserRole", transact(graphQLMutation.setUserRole())) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/slotcopyrecord/SlotCopyRecord.java b/src/main/java/uk/ac/sanger/sccp/stan/model/slotcopyrecord/SlotCopyRecord.java new file mode 100644 index 00000000..049298e9 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/slotcopyrecord/SlotCopyRecord.java @@ -0,0 +1,108 @@ +package uk.ac.sanger.sccp.stan.model.slotcopyrecord; + +import uk.ac.sanger.sccp.stan.model.OperationType; +import uk.ac.sanger.sccp.stan.model.Work; +import uk.ac.sanger.sccp.utils.BasicUtils; + +import javax.persistence.*; +import java.util.*; + +/** + * A record of a saved slot copy request + * @author dr6 + */ +@Entity +public class SlotCopyRecord { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + @ManyToOne + private OperationType operationType; + @ManyToOne + private Work work; + private String lpNumber; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "slot_copy_record_id", nullable = false) + private Set notes = new HashSet<>(); + + // Required no-arg constructor + public SlotCopyRecord() {} + + public SlotCopyRecord(OperationType operationType, Work work, String lpNumber) { + this.operationType = operationType; + this.work = work; + this.lpNumber = lpNumber; + } + + public Integer getId() { + return this.id; + } + + public void setId(Integer id) { + this.id = id; + } + + public OperationType getOperationType() { + return this.operationType; + } + + public void setOperationType(OperationType operationType) { + this.operationType = operationType; + } + + public Work getWork() { + return this.work; + } + + public void setWork(Work work) { + this.work = work; + } + public String getLpNumber() { + return this.lpNumber; + } + + public void setLpNumber(String lpNumber) { + this.lpNumber = lpNumber; + } + + public Set getNotes() { + return this.notes; + } + + public void setNotes(Collection notes) { + this.notes.clear(); + if (notes != null) { + this.notes.addAll(notes); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SlotCopyRecord that = (SlotCopyRecord) o; + return (Objects.equals(this.id, that.id) + && Objects.equals(this.operationType, that.operationType) + && Objects.equals(this.work, that.work) + && Objects.equals(this.lpNumber, that.lpNumber) + && Objects.equals(this.notes, that.notes) + ); + } + + @Override + public int hashCode() { + return (id!=null ? id.hashCode() : Objects.hash(work, lpNumber)); + } + + @Override + public String toString() { + return BasicUtils.describe(this) + .add("id", id) + .add("operationType", operationType) + .add("work", work) + .add("lpNumber", lpNumber) + .add("notes", notes) + .toString(); + } +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/slotcopyrecord/SlotCopyRecordNote.java b/src/main/java/uk/ac/sanger/sccp/stan/model/slotcopyrecord/SlotCopyRecordNote.java new file mode 100644 index 00000000..af1f647a --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/slotcopyrecord/SlotCopyRecordNote.java @@ -0,0 +1,98 @@ +package uk.ac.sanger.sccp.stan.model.slotcopyrecord; + +import org.jetbrains.annotations.NotNull; + +import javax.persistence.*; +import java.util.Objects; + +import static uk.ac.sanger.sccp.utils.BasicUtils.repr; + +/** + * A key/value with an index, linked to a slot copy record + * @author dr6 + */ +@Entity +@Table(name="slot_copy_record_note") +public class SlotCopyRecordNote implements Comparable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + private String name; + private int valueIndex = 0; + private String value; + + public SlotCopyRecordNote() {} // required + + public SlotCopyRecordNote(String name, int valueIndex, String value) { + this.name = name; + this.valueIndex = valueIndex; + this.value = value; + } + + public SlotCopyRecordNote(String name, String value) { + this(name, 0, value); + } + + public Integer getId() { + return this.id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public int getValueIndex() { + return this.valueIndex; + } + + public void setValueIndex(int index) { + this.valueIndex = index; + } + + public String getValue() { + return this.value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SlotCopyRecordNote that = (SlotCopyRecordNote) o; + return (Objects.equals(this.id, that.id) + && Objects.equals(this.name, that.name) + && this.valueIndex == that.valueIndex + && Objects.equals(this.value, that.value)); + } + + @Override + public int hashCode() { + return id!=null ? id.hashCode() : Objects.hash(name, valueIndex, value); + } + + @Override + public String toString() { + return String.format("[%s:%s=%s]", name, valueIndex, repr(value)); + } + + @Override + public int compareTo(@NotNull SlotCopyRecordNote o) { + int n = this.name.compareTo(o.name); + if (n != 0) { + return n; + } + return Integer.compare(this.valueIndex, o.valueIndex); + } +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/repo/SlotCopyRecordRepo.java b/src/main/java/uk/ac/sanger/sccp/stan/repo/SlotCopyRecordRepo.java new file mode 100644 index 00000000..6adf0275 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/repo/SlotCopyRecordRepo.java @@ -0,0 +1,12 @@ +package uk.ac.sanger.sccp.stan.repo; + +import org.springframework.data.repository.CrudRepository; +import uk.ac.sanger.sccp.stan.model.OperationType; +import uk.ac.sanger.sccp.stan.model.Work; +import uk.ac.sanger.sccp.stan.model.slotcopyrecord.SlotCopyRecord; + +import java.util.Optional; + +public interface SlotCopyRecordRepo extends CrudRepository { + Optional findByOperationTypeAndWorkAndLpNumber(OperationType opType, Work work, String lpNumber); +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/SlotCopyRequest.java b/src/main/java/uk/ac/sanger/sccp/stan/request/SlotCopyRequest.java index 3590b91e..28f27d61 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/SlotCopyRequest.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/SlotCopyRequest.java @@ -155,6 +155,20 @@ public void setLabwareState(Labware.State labwareState) { public String toString() { return repr(barcode)+": "+labwareState; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SlotCopySource that = (SlotCopySource) o; + return (Objects.equals(this.barcode, that.barcode) + && this.labwareState == that.labwareState); + } + + @Override + public int hashCode() { + return Objects.hash(barcode, labwareState); + } } /** diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/SlotCopySave.java b/src/main/java/uk/ac/sanger/sccp/stan/request/SlotCopySave.java new file mode 100644 index 00000000..f47d3b7d --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/SlotCopySave.java @@ -0,0 +1,195 @@ +package uk.ac.sanger.sccp.stan.request; + +import uk.ac.sanger.sccp.stan.model.ExecutionType; +import uk.ac.sanger.sccp.stan.model.SlideCosting; +import uk.ac.sanger.sccp.stan.request.SlotCopyRequest.SlotCopyContent; +import uk.ac.sanger.sccp.stan.request.SlotCopyRequest.SlotCopySource; + +import java.util.List; +import java.util.Objects; + +import static uk.ac.sanger.sccp.utils.BasicUtils.describe; +import static uk.ac.sanger.sccp.utils.BasicUtils.nullToEmpty; + +/** + * Saved data for an incomplete slot copy operation + * @author dr6 + */ +public class SlotCopySave { + private List sources = List.of(); + private String operationType; + private String workNumber; + private String lpNumber; + private ExecutionType executionType; + private String labwareType; + private String barcode; + private String bioState; + private SlideCosting costing; + private String lotNumber; + private String probeLotNumber; + private String preBarcode; + private List contents = List.of(); + + /** The source labware and their new labware states (if specified). */ + public List getSources() { + return this.sources; + } + + public void setSources(List sources) { + this.sources = nullToEmpty(sources); + } + + /** The name of the type of operation being recorded to describe the contents being copied. */ + public String getOperationType() { + return this.operationType; + } + + public void setOperationType(String operationType) { + this.operationType = operationType; + } + + /** An optional work number to associate with this operation. */ + public String getWorkNumber() { + return this.workNumber; + } + + public void setWorkNumber(String workNumber) { + this.workNumber = workNumber; + } + + /** The LP number of the new labware, required. */ + public String getLpNumber() { + return this.lpNumber; + } + + public void setLpNumber(String lpNumber) { + this.lpNumber = lpNumber; + } + + /** Whether the execution was automated or manual. */ + public ExecutionType getExecutionType() { + return this.executionType; + } + + public void setExecutionType(ExecutionType executionType) { + this.executionType = executionType; + } + + /** The name of the type of the destination labware (if it is new labware). */ + public String getLabwareType() { + return this.labwareType; + } + + public void setLabwareType(String labwareType) { + this.labwareType = labwareType; + } + + /** The barcode of the existing piece of labware. */ + public String getBarcode() { + return this.barcode; + } + + public void setBarcode(String barcode) { + this.barcode = barcode; + } + + /** The bio state for samples in the destination (if specified). */ + public String getBioState() { + return this.bioState; + } + + public void setBioState(String bioState) { + this.bioState = bioState; + } + + /** The costing of the slide, if specified. */ + public SlideCosting getCosting() { + return this.costing; + } + + public void setCosting(SlideCosting costing) { + this.costing = costing; + } + + /** The lot number of the slide, if specified. */ + public String getLotNumber() { + return this.lotNumber; + } + + public void setLotNumber(String lotNumber) { + this.lotNumber = lotNumber; + } + + /** The probe lot number of the slide, if specified. */ + public String getProbeLotNumber() { + return this.probeLotNumber; + } + + public void setProbeLotNumber(String probeLotNumber) { + this.probeLotNumber = probeLotNumber; + } + + /** The barcode of the new labware, if it is prebarcoded. */ + public String getPreBarcode() { + return this.preBarcode; + } + + public void setPreBarcode(String preBarcode) { + this.preBarcode = preBarcode; + } + + /** The specifications of which source slots are being copied into what addresses in the destination labware. */ + public List getContents() { + return this.contents; + } + + public void setContents(List contents) { + this.contents = nullToEmpty(contents); + } + + @Override + public String toString() { + return describe(this) + .add("sources", sources) + .add("operationType", operationType) + .add("workNumber", workNumber) + .add("lpNumber", lpNumber) + .add("executionType", executionType) + .add("labwareType", labwareType) + .add("barcode", barcode) + .add("bioState", bioState) + .add("costing", costing) + .add("lotNumber", lotNumber) + .add("probeLotNumber", probeLotNumber) + .add("preBarcode", preBarcode) + .add("contents", contents) + .reprStringValues() + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || o.getClass() != this.getClass()) return false; + SlotCopySave that = (SlotCopySave) o; + return (Objects.equals(this.sources, that.sources) + && Objects.equals(this.operationType, that.operationType) + && Objects.equals(this.workNumber, that.workNumber) + && Objects.equals(this.lpNumber, that.lpNumber) + && Objects.equals(this.executionType, that.executionType) + && Objects.equals(this.labwareType, that.labwareType) + && Objects.equals(this.barcode, that.barcode) + && Objects.equals(this.bioState, that.bioState) + && Objects.equals(this.costing, that.costing) + && Objects.equals(this.lotNumber, that.lotNumber) + && Objects.equals(this.probeLotNumber, that.probeLotNumber) + && Objects.equals(this.preBarcode, that.preBarcode) + && Objects.equals(this.contents, that.contents) + ); + } + + @Override + public int hashCode() { + return Objects.hash(workNumber, lpNumber); + } +} \ No newline at end of file diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordService.java new file mode 100644 index 00000000..c8c340cf --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordService.java @@ -0,0 +1,15 @@ +package uk.ac.sanger.sccp.stan.service; + +import uk.ac.sanger.sccp.stan.model.slotcopyrecord.SlotCopyRecord; +import uk.ac.sanger.sccp.stan.request.SlotCopySave; + +import javax.persistence.EntityNotFoundException; + +/** Loads and saves {@link SlotCopyRecord}s */ +public interface SlotCopyRecordService { + /** Converts the given slot copy info to a record saved in the database */ + SlotCopyRecord save(SlotCopySave request) throws ValidationException; + + /** Looks up the indicated slot copy data from the database */ + SlotCopySave load(String opname, String workNumber, String lpNumber) throws EntityNotFoundException; +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordServiceImp.java new file mode 100644 index 00000000..695f088c --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordServiceImp.java @@ -0,0 +1,259 @@ +package uk.ac.sanger.sccp.stan.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.model.slotcopyrecord.SlotCopyRecord; +import uk.ac.sanger.sccp.stan.model.slotcopyrecord.SlotCopyRecordNote; +import uk.ac.sanger.sccp.stan.repo.*; +import uk.ac.sanger.sccp.stan.request.SlotCopyRequest.SlotCopyContent; +import uk.ac.sanger.sccp.stan.request.SlotCopyRequest.SlotCopySource; +import uk.ac.sanger.sccp.stan.request.SlotCopySave; +import uk.ac.sanger.sccp.utils.Zip; + +import javax.persistence.EntityManager; +import javax.persistence.EntityNotFoundException; +import java.util.*; +import java.util.function.Function; +import java.util.stream.IntStream; + +import static uk.ac.sanger.sccp.utils.BasicUtils.*; + +/** + * @author dr6 + */ +@Service +public class SlotCopyRecordServiceImp implements SlotCopyRecordService { + public static final String + NOTE_BARCODE = "barcode", + NOTE_LWTYPE = "labware type", + NOTE_PREBARCODE = "prebarcode", + NOTE_BIOSTATE = "bio state", + NOTE_COSTING = "costing", + NOTE_LOT = "lot", + NOTE_PROBELOT = "probe lot", + NOTE_EXECUTION = "execution", + NOTE_CON_SRCBC = "con source barcode", + NOTE_CON_SRCADDRESS = "con source address", + NOTE_CON_DESTADDRESS = "con dest address", + NOTE_SRC_BARCODE = "source barcode", + NOTE_SRC_STATE = "source state"; + + private final SlotCopyRecordRepo recordRepo; + private final OperationTypeRepo opTypeRepo; + private final WorkRepo workRepo; + private final EntityManager entityManager; + + @Autowired + public SlotCopyRecordServiceImp(SlotCopyRecordRepo recordRepo, OperationTypeRepo opTypeRepo, WorkRepo workRepo, EntityManager entityManager) { + this.recordRepo = recordRepo; + this.opTypeRepo = opTypeRepo; + this.workRepo = workRepo; + this.entityManager = entityManager; + } + + @Override + public SlotCopyRecord save(SlotCopySave request) throws ValidationException { + Set problems = new LinkedHashSet<>(); + checkRequiredFields(problems, request); + if (!problems.isEmpty()) { + throw new ValidationException(problems); + } + Work work = workRepo.getByWorkNumber(request.getWorkNumber()); + OperationType opType = opTypeRepo.getByName(request.getOperationType()); + Optional oldRecordOpt = recordRepo.findByOperationTypeAndWorkAndLpNumber(opType, work, request.getLpNumber()); + if (oldRecordOpt.isPresent()) { + recordRepo.delete(oldRecordOpt.get()); + entityManager.flush(); + } + + SlotCopyRecord record = new SlotCopyRecord(); + record.setOperationType(opType); + record.setWork(work); + record.setLpNumber(request.getLpNumber()); + record.setNotes(createNotes(request)); + + return recordRepo.save(record); + } + + @Override + public SlotCopySave load(String opname, String workNumber, String lpNumber) throws EntityNotFoundException { + OperationType opType = opTypeRepo.getByName(opname); + Work work = workRepo.getByWorkNumber(workNumber); + lpNumber = trimAndRequire(lpNumber, "LP number not supplied."); + SlotCopyRecord record = recordRepo.findByOperationTypeAndWorkAndLpNumber(opType, work, lpNumber).orElse(null); + if (record==null) { + throw new EntityNotFoundException("No such record found."); + } + return reassembleSave(record); + } + + /** Constructs slot copy save data from a slot copy record */ + SlotCopySave reassembleSave(SlotCopyRecord record) { + Map> noteMap = loadNoteMap(record.getNotes()); + SlotCopySave save = new SlotCopySave(); + save.setLpNumber(record.getLpNumber()); + save.setWorkNumber(record.getWork().getWorkNumber()); + save.setOperationType(record.getOperationType().getName()); + save.setBarcode(singleNoteValue(noteMap, NOTE_BARCODE)); + save.setLabwareType(singleNoteValue(noteMap, NOTE_LWTYPE)); + save.setPreBarcode(singleNoteValue(noteMap, NOTE_PREBARCODE)); + save.setBioState(singleNoteValue(noteMap, NOTE_BIOSTATE)); + save.setCosting(nullableValueOf(singleNoteValue(noteMap, NOTE_COSTING), SlideCosting::valueOf)); + save.setLotNumber(singleNoteValue(noteMap, NOTE_LOT)); + save.setProbeLotNumber(singleNoteValue(noteMap, NOTE_PROBELOT)); + save.setExecutionType(nullableValueOf(singleNoteValue(noteMap, NOTE_EXECUTION), ExecutionType::valueOf)); + List sourceBarcodes = noteMap.get(NOTE_SRC_BARCODE); + List sourceStates = noteMap.get(NOTE_SRC_STATE); + save.setSources(Zip.map(sourceBarcodes.stream(), sourceStates.stream(), + (bc, state) -> new SlotCopySource(bc, nullableValueOf(state, Labware.State::valueOf)) + ).toList()); + List contentSourceBarcodes = noteMap.get(NOTE_CON_SRCBC); + List contentSourceAddress = noteMap.get(NOTE_CON_SRCADDRESS); + List contentDestAddress = noteMap.get(NOTE_CON_DESTADDRESS); + save.setContents(IntStream.range(0, contentSourceBarcodes.size()).mapToObj( + i -> new SlotCopyContent(contentSourceBarcodes.get(i), + nullableValueOf(contentSourceAddress.get(i), Address::valueOf), + nullableValueOf(contentDestAddress.get(i), Address::valueOf)) + ).toList()); + return save; + } + + /** + * Returns null of the given string is null or empty; otherwise uses the given function to convert it. + * @param string string value + * @param function function to convert the string + * @return the value converted into; or null if the string is null or empty + * @param the type of value the string is converted into + */ + static E nullableValueOf(String string, Function function) { + if (nullOrEmpty(string)) { + return null; + } + return function.apply(string); + } + + /** + * Trims the given string; adds a problem if it is null or empty + * @param problems receptacle for problems + * @param name the name of the field + * @param value the value of the field + * @return the trimmed value of the string; null if the string is empty + */ + String trimAndCheck(Collection problems, String name, String value) { + value = trimToNull(value); + if (value==null) { + problems.add("Missing "+name+"."); + } + return value; + } + + /** + * Checks required fields are present + * @param problems receptacle for problems + * @param request request to validate + */ + void checkRequiredFields(Collection problems, SlotCopySave request) { + request.setOperationType(trimAndCheck(problems, "operation type", request.getOperationType())); + request.setWorkNumber(trimAndCheck(problems, "work number", request.getWorkNumber())); + request.setLpNumber(trimAndCheck(problems, "LP number", request.getLpNumber())); + } + + /** + * Adds a note to the given list of notes, if the given value is non-null and nonempty + * @param notes list to add note + * @param name name of note + * @param value value of note + */ + void mayAddNote(List notes, String name, String value) { + mayAddNote(notes, name, 0, value); + } + + /** + * Adds a note to the given list of notes, if the given value is non-null and nonempty + * @param notes list to add note + * @param name name of note + * @param index index of note to add + * @param value value of note + */ + void mayAddNote(List notes, String name, int index, String value) { + value = trimToNull(value); + if (value != null) { + notes.add(new SlotCopyRecordNote(name, index, value)); + } + } + + /** + * Compiles a list of notes for the details of the request + * @param request the request to describe + * @return a list of notes describing details of the request + */ + List createNotes(SlotCopySave request) { + List notes = new ArrayList<>(); + mayAddNote(notes, NOTE_BARCODE, request.getBarcode()); + mayAddNote(notes, NOTE_LWTYPE, request.getLabwareType()); + mayAddNote(notes, NOTE_PREBARCODE, request.getPreBarcode()); + mayAddNote(notes, NOTE_BIOSTATE, request.getBioState()); + mayAddNote(notes, NOTE_LOT, request.getLotNumber()); + mayAddNote(notes, NOTE_PROBELOT, request.getProbeLotNumber()); + if (request.getExecutionType()!=null) { + notes.add(new SlotCopyRecordNote(NOTE_EXECUTION, request.getExecutionType().name())); + } + if (request.getCosting()!=null) { + notes.add(new SlotCopyRecordNote(NOTE_COSTING, request.getCosting().name())); + } + + for (int i = 0; i < request.getSources().size(); ++i) { + SlotCopySource source = request.getSources().get(i); + mayAddNote(notes, NOTE_SRC_BARCODE, i, source.getBarcode()); + if (source.getLabwareState()!=null) { + notes.add(new SlotCopyRecordNote(NOTE_SRC_STATE, i, source.getLabwareState().name())); + } + } + for (int i = 0; i < request.getContents().size(); ++i) { + SlotCopyContent content = request.getContents().get(i); + mayAddNote(notes, NOTE_CON_SRCBC, i, content.getSourceBarcode()); + if (content.getSourceAddress()!=null) { + notes.add(new SlotCopyRecordNote(NOTE_CON_SRCADDRESS, i, content.getSourceAddress().toString())); + } + if (content.getDestinationAddress()!=null) { + notes.add(new SlotCopyRecordNote(NOTE_CON_DESTADDRESS, i, content.getDestinationAddress().toString())); + } + } + return notes; + } + + /** + * Loads given note values into a map + * @param notes notes to load + * @return a map of note name to list of values + */ + Map> loadNoteMap(Collection notes) { + Map> map = new HashMap<>(); + notes = notes.stream() + .sorted(Comparator.naturalOrder()) + .toList(); + for (SlotCopyRecordNote note : notes) { + List list = map.computeIfAbsent(note.getName(), k -> new ArrayList<>()); + while (list.size() <= note.getValueIndex()) { + list.add(null); + } + list.set(note.getValueIndex(), note.getValue()); + } + return map; + } + + /** + * Gets the single named value from the given map of notes + * @param noteMap map of names to note values + * @param key name of the note + * @return the value of the note; or null of it is missing or empty + */ + String singleNoteValue(Map> noteMap, String key) { + List values = noteMap.get(key); + if (nullOrEmpty(values)) { + return null; + } + return values.getFirst(); + } +} diff --git a/src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java b/src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java index 90b78eed..1d49eac4 100644 --- a/src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java +++ b/src/main/java/uk/ac/sanger/sccp/utils/BasicUtils.java @@ -530,6 +530,15 @@ public static String emptyToNull(String string) { return (string==null || string.isEmpty() ? null : string); } + /** + * Returns the given string trimmed, and null if the trimmed string is empty + * @param string the given string, or null + * @return the trimmed non-empty string, or null + */ + public static String trimToNull(String string) { + return (string==null ? null : emptyToNull(string.trim())); + } + /** * If the given list is non-null, it is returned. Otherwise, returns the immutable empty list. * @param list list or null diff --git a/src/main/resources/db/changelog/changelog-3.30.xml b/src/main/resources/db/changelog/changelog-3.30.xml new file mode 100644 index 00000000..4611f018 --- /dev/null +++ b/src/main/resources/db/changelog/changelog-3.30.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/changelog-master.xml b/src/main/resources/db/changelog/changelog-master.xml index 7b198c5e..56ad53f2 100644 --- a/src/main/resources/db/changelog/changelog-master.xml +++ b/src/main/resources/db/changelog/changelog-master.xml @@ -32,4 +32,5 @@ + diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index 139274a0..1b1271f6 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -637,6 +637,84 @@ input SlotCopyRequest { executionType: ExecutionType } +"""Saved data for an incomplete slot copy operation.""" +input SlotCopySave { + """The source labware and their new labware states (if specified).""" + sources: [SlotCopySource!]! + """The name of the type of operation being recorded to describe the contents being copied.""" + operationType: String! + """An optional work number to associate with this operation.""" + workNumber: String! + """The LP number of the new labware, required.""" + lpNumber: String! + """Whether the execution was automated or manual.""" + executionType: ExecutionType + """The name of the type of the destination labware (if it is new labware).""" + labwareType: String + """The barcode of the existing piece of labware.""" + barcode: String + """The bio state for samples in the destination (if specified).""" + bioState: String + """The costing of the slide, if specified.""" + costing: SlideCosting + """The lot number of the slide, if specified.""" + lotNumber: String + """The probe lot number of the slide, if specified.""" + probeLotNumber: String + """The barcode of the new labware, if it is prebarcoded.""" + preBarcode: String + """The specifications of which source slots are being copied into what addresses in the destination labware.""" + contents: [SlotCopyContent!]! +} + +"""Loaded data about a source labware for an incomplete slot copy operation.""" +type SlotCopyLoadSource { + """The barcode of the source.""" + barcode: String! + """The new labware state of the source.""" + labwareState: LabwareState! +} + +"""Loaded data about a transfer in an incomplete slot copy operation.""" +type SlotCopyLoadContent { + """The barcode of the source labware.""" + sourceBarcode: String! + """The address of the source slot in its labware.""" + sourceAddress: Address! + """The address of the destination slot.""" + destinationAddress: Address! +} + +"""Loaded data for an incomplete slot copy operation.""" +type SlotCopyLoad { + """The source labware and their new labware states (if specified).""" + sources: [SlotCopyLoadSource!]! + """The name of the type of operation being recorded to describe the contents being copied.""" + operationType: String! + """An optional work number to associate with this operation.""" + workNumber: String! + """The LP number of the new labware, required.""" + lpNumber: String! + """Whether the execution was automated or manual.""" + executionType: ExecutionType + """The name of the type of the destination labware (if it is new labware).""" + labwareType: String + """The barcode of the existing piece of labware.""" + barcode: String + """The bio state for samples in the destination (if specified).""" + bioState: String + """The costing of the slide, if specified.""" + costing: SlideCosting + """The lot number of the slide, if specified.""" + lotNumber: String + """The probe lot number of the slide, if specified.""" + probeLotNumber: String + """The barcode of the new labware, if it is prebarcoded.""" + preBarcode: String + """The specifications of which source slots are being copied into what addresses in the destination labware.""" + contents: [SlotCopyLoadContent!]! +} + """A request to record an operation in place.""" input InPlaceOpRequest { """The name of the type of operation being recorded.""" @@ -2143,6 +2221,8 @@ type Query { runNames(barcode: String!): [String!]! """Bio risk codes for samples in the specified labware.""" labwareBioRiskCodes(barcode: String!): [SampleBioRisk!]! + """Reloads saved slot copy information.""" + reloadSlotCopy(operationType: String!, workNumber: String!, lpNumber: String!): SlotCopyLoad """Get the specified storage location.""" location(locationBarcode: String!): Location! @@ -2343,6 +2423,8 @@ type Mutation { cleanOut(request: CleanOutRequest!) : OperationResult! """Record metrics.""" recordSampleMetrics(request: SampleMetricsRequest!): OperationResult! + """Save slot copy information for a future operation.""" + saveSlotCopy(request: SlotCopySave!): SlotCopyLoad! """Create a new user for the application.""" addUser(username: String!): User! diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSaveSlotCopyMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSaveSlotCopyMutation.java new file mode 100644 index 00000000..08c0c5a9 --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSaveSlotCopyMutation.java @@ -0,0 +1,86 @@ +package uk.ac.sanger.sccp.stan.integrationtest; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import uk.ac.sanger.sccp.stan.EntityCreator; +import uk.ac.sanger.sccp.stan.GraphQLTester; +import uk.ac.sanger.sccp.stan.model.*; + +import javax.transaction.Transactional; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static uk.ac.sanger.sccp.stan.integrationtest.IntegrationTestUtils.chainGet; + +/** + * Tests save/resume slot copy + * @author dr6 + */ +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +@Import({GraphQLTester.class, EntityCreator.class}) +public class TestSaveSlotCopyMutation { + @Autowired + private GraphQLTester tester; + @Autowired + private EntityCreator entityCreator; + + @Test + @Transactional + public void testSlotCopySave() throws Exception { + User user = entityCreator.createUser("user1"); + Work work = entityCreator.createWork(null, null, null, null, null); + OperationType opType = entityCreator.createOpType("opname", null); + tester.setUser(user); + String mutation = tester.readGraphQL("saveslotcopy.graphql").replace("[WORK]", work.getWorkNumber()); + + Object response = tester.post(mutation); + Map data = chainGet(response, "data", "saveSlotCopy"); + assertEquals("STAN-A", data.get("barcode")); + assertEquals(opType.getName(), data.get("operationType")); + assertEquals(work.getWorkNumber(), data.get("workNumber")); + assertEquals("LP1", data.get("lpNumber")); + + String query = tester.readGraphQL("reloadslotcopy.graphql").replace("[WORK]", work.getWorkNumber()); + + response = tester.post(query); + data = chainGet(response, "data", "reloadSlotCopy"); + assertEquals(opType.getName(), data.get("operationType")); + assertEquals("STAN-A", data.get("barcode")); + assertEquals(work.getWorkNumber(), data.get("workNumber")); + assertEquals("LP1", data.get("lpNumber")); + assertEquals("manual", data.get("executionType")); + assertEquals("pb1", data.get("preBarcode")); + assertEquals("lt1", data.get("labwareType")); + assertEquals("lot1", data.get("lotNumber")); + assertEquals("probe1", data.get("probeLotNumber")); + assertEquals("bs", data.get("bioState")); + assertEquals("SGP", data.get("costing")); + List> sourceData = chainGet(data, "sources"); + List> contentData = chainGet(data, "contents"); + assertThat(sourceData).containsExactly(Map.of("barcode", "STAN-0", "labwareState", "active")); + assertThat(contentData).containsExactly( + Map.of("sourceBarcode", "STAN-0", "sourceAddress", "A2", "destinationAddress", "A1"), + Map.of("sourceBarcode", "STAN-1", "sourceAddress", "A1", "destinationAddress", "A2") + ); + + // Replace the save with a new save + mutation = mutation.replace("pb1", "pb2").replace("costing: SGP", "costing: Faculty") + .replace("manual", "automated"); + response = tester.post(mutation); + assertEquals("STAN-A", chainGet(response, "data", "saveSlotCopy", "barcode")); + response = tester.post(query); + + data = chainGet(response, "data", "reloadSlotCopy"); + assertEquals("pb2", data.get("preBarcode")); + assertEquals("automated", data.get("executionType")); + assertEquals("Faculty", data.get("costing")); + } +} diff --git a/src/test/java/uk/ac/sanger/sccp/stan/repo/TestSlotCopyRecordRepo.java b/src/test/java/uk/ac/sanger/sccp/stan/repo/TestSlotCopyRecordRepo.java new file mode 100644 index 00000000..1ce8b869 --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/stan/repo/TestSlotCopyRecordRepo.java @@ -0,0 +1,117 @@ +package uk.ac.sanger.sccp.stan.repo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import uk.ac.sanger.sccp.stan.EntityCreator; +import uk.ac.sanger.sccp.stan.model.OperationType; +import uk.ac.sanger.sccp.stan.model.Work; +import uk.ac.sanger.sccp.stan.model.slotcopyrecord.SlotCopyRecord; +import uk.ac.sanger.sccp.stan.model.slotcopyrecord.SlotCopyRecordNote; + +import javax.persistence.EntityManager; +import javax.transaction.Transactional; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests {@link SlotCopyRecordRepo} + * @author dr6 + */ +@SpringBootTest +@ActiveProfiles(profiles = "test") +@Import(EntityCreator.class) +class TestSlotCopyRecordRepo { + @Autowired + private SlotCopyRecordRepo repo; + @Autowired + private EntityCreator entityCreator; + @Autowired + private EntityManager entityManager; + + private OperationType opType; + private Work work; + + @BeforeEach + void setup() { + opType = entityCreator.createOpType("opname", null); + work = entityCreator.createWork(null, null, null, null, null); + } + + @Test + @Transactional + void testSlotCopyRecord_minimal() { + SlotCopyRecord record = new SlotCopyRecord(opType, work, "LP1"); + record = repo.save(record); + assertNotNull(record); + assertNotNull(record.getId()); + SlotCopyRecord loaded = repo.findByOperationTypeAndWorkAndLpNumber(opType, work, record.getLpNumber()).orElseThrow(); + assertNotNull(loaded); + assertEquals(opType, loaded.getOperationType()); + assertEquals(work, loaded.getWork()); + assertEquals(loaded.getId(), record.getId()); + assertThat(loaded.getNotes()).isEmpty(); + } + + @Transactional + @Test + void testSlotCopyRecord_maximal() { + SlotCopyRecord record = new SlotCopyRecord(opType, work, "LP1"); + record.setNotes(List.of(new SlotCopyRecordNote("Alpha", "Alabama"), + new SlotCopyRecordNote("Beta", 3, "Banana"))); + + record = repo.save(record); + SlotCopyRecord loaded = repo.findByOperationTypeAndWorkAndLpNumber(opType, work, record.getLpNumber()).orElseThrow(); + entityManager.refresh(loaded); + assertNotNull(loaded); + assertEquals(opType, loaded.getOperationType()); + assertEquals(work, loaded.getWork()); + assertEquals("LP1", loaded.getLpNumber()); + + assertThat(loaded.getNotes()).hasSize(2); + List notes = loaded.getNotes().stream() + .sorted(Comparator.naturalOrder()) + .toList(); + for (int i = 0; i < notes.size(); i++) { + SlotCopyRecordNote note = notes.get(i); + assertEquals(i==0 ? "Alpha" : "Beta", note.getName()); + assertEquals(i==0 ? "Alabama" : "Banana", note.getValue()); + assertEquals(i==0 ? 0 : 3, note.getValueIndex()); + } + } + + @Transactional + @Test + void testUpdate() { + SlotCopyRecord record = new SlotCopyRecord(opType, work, "LP1"); + record.setNotes(List.of(new SlotCopyRecordNote("Alpha", 0, "Alpha0"), + new SlotCopyRecordNote("Alpha", 1, "Alpha1"), + new SlotCopyRecordNote("Beta", "Banana"))); + record = repo.save(record); + + entityManager.flush(); + + Integer id = record.getId(); + + record = repo.findById(id).orElseThrow(); + record.setNotes(List.of(new SlotCopyRecordNote("Alpha", 0, "Alabama"))); + + repo.save(record); + + entityManager.flush(); + entityManager.refresh(record); + assertThat(record.getNotes()).hasSize(1); + SlotCopyRecordNote note = record.getNotes().iterator().next(); + assertNotNull(note.getId()); + assertEquals("Alpha", note.getName()); + assertEquals(0, note.getValueIndex()); + assertEquals("Alabama", note.getValue()); + } +} \ No newline at end of file diff --git a/src/test/java/uk/ac/sanger/sccp/stan/repo/TestTagLayoutRepo.java b/src/test/java/uk/ac/sanger/sccp/stan/repo/TestTagLayoutRepo.java index cd8431ba..21265065 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/repo/TestTagLayoutRepo.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/repo/TestTagLayoutRepo.java @@ -5,10 +5,8 @@ import org.junit.jupiter.params.provider.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.jdbc.Sql; -import uk.ac.sanger.sccp.stan.EntityCreator; import uk.ac.sanger.sccp.stan.model.Address; import uk.ac.sanger.sccp.stan.model.reagentplate.ReagentPlate; import uk.ac.sanger.sccp.stan.model.taglayout.TagHeading; @@ -30,18 +28,9 @@ @SpringBootTest @ActiveProfiles(profiles = "test") @Sql("/testdata/tag_layout_test.sql") -@Import(EntityCreator.class) public class TestTagLayoutRepo { @Autowired TagLayoutRepo tagLayoutRepo; - @Autowired - LabwareTypeRepo lwTypeRepo; - @Autowired - OperationRepo opRepo; - @Autowired - ReagentPlateRepo reagentPlateRepo; - @Autowired - EntityCreator entityCreator; @Test @Transactional diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyRecordService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyRecordService.java new file mode 100644 index 00000000..e8b6abc1 --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyRecordService.java @@ -0,0 +1,324 @@ +package uk.ac.sanger.sccp.stan.service; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import uk.ac.sanger.sccp.stan.EntityFactory; +import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.model.slotcopyrecord.SlotCopyRecord; +import uk.ac.sanger.sccp.stan.model.slotcopyrecord.SlotCopyRecordNote; +import uk.ac.sanger.sccp.stan.repo.*; +import uk.ac.sanger.sccp.stan.request.SlotCopyRequest.SlotCopyContent; +import uk.ac.sanger.sccp.stan.request.SlotCopyRequest.SlotCopySource; +import uk.ac.sanger.sccp.stan.request.SlotCopySave; + +import javax.persistence.EntityManager; +import javax.persistence.EntityNotFoundException; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static uk.ac.sanger.sccp.stan.Matchers.addProblem; +import static uk.ac.sanger.sccp.stan.Matchers.assertValidationException; +import static uk.ac.sanger.sccp.stan.service.SlotCopyRecordServiceImp.*; + +/** + * Test {@link SlotCopyRecordServiceImp} + */ +class TestSlotCopyRecordService { + @Mock + private SlotCopyRecordRepo mockRecordRepo; + @Mock + private OperationTypeRepo mockOpTypeRepo; + @Mock + private WorkRepo mockWorkRepo; + @Mock + private EntityManager mockEntityManager; + + @InjectMocks + private SlotCopyRecordServiceImp service; + + private AutoCloseable mocking; + + @BeforeEach + void setup() { + mocking = MockitoAnnotations.openMocks(this); + service = spy(service); + } + + @AfterEach + void cleanup() throws Exception { + mocking.close(); + } + + @ParameterizedTest + @ValueSource(booleans={true, false}) + void testSave(boolean exists) { + Work work = EntityFactory.makeWork("SGP1"); + when(mockWorkRepo.getByWorkNumber(work.getWorkNumber())).thenReturn(work); + OperationType opType = EntityFactory.makeOperationType("opname", null); + when(mockOpTypeRepo.getByName(opType.getName())).thenReturn(opType); + SlotCopySave save = new SlotCopySave(); + save.setOperationType(opType.getName()); + save.setWorkNumber(work.getWorkNumber()); + final String LP = "LP1"; + save.setLpNumber(LP); + + doNothing().when(service).checkRequiredFields(any(), any()); + + SlotCopyRecord existing; + if (exists) { + existing = new SlotCopyRecord(); + existing.setLpNumber(LP); + existing.setOperationType(opType); + existing.setWork(work); + existing.setId(700); + when(mockRecordRepo.findByOperationTypeAndWorkAndLpNumber(any(), any(), any())).thenReturn(Optional.of(existing)); + } else { + existing = null; + when(mockRecordRepo.findByOperationTypeAndWorkAndLpNumber(any(), any(), any())).thenReturn(Optional.empty()); + } + List notes = List.of(new SlotCopyRecordNote("Alpha", "Beta")); + doReturn(notes).when(service).createNotes(any()); + when(mockRecordRepo.save(any())).then(invocation -> { + SlotCopyRecord record = invocation.getArgument(0); + record.setId(800); + return record; + }); + + SlotCopyRecord record = service.save(save); + + verify(service).checkRequiredFields(any(), same(save)); + verify(mockWorkRepo).getByWorkNumber(work.getWorkNumber()); + verify(mockOpTypeRepo).getByName(opType.getName()); + verify(mockRecordRepo).findByOperationTypeAndWorkAndLpNumber(opType, work, LP); + + if (exists) { + verify(mockRecordRepo).delete(existing); + verify(mockEntityManager).flush(); + } else { + verify(mockRecordRepo, never()).delete(any()); + } + + assertNotNull(record.getId()); + assertEquals(opType, record.getOperationType()); + assertEquals(work, record.getWork()); + assertEquals(LP, record.getLpNumber()); + assertThat(record.getNotes()).containsExactlyInAnyOrderElementsOf(notes); + verify(mockRecordRepo).save(record); + } + + @Test + void testSave_invalid() { + SlotCopySave save = new SlotCopySave(); + doAnswer(addProblem("Bad stuff")).when(service).checkRequiredFields(any(), any()); + assertValidationException(() -> service.save(save), List.of("Bad stuff")); + verifyNoMoreInteractions(mockRecordRepo); + } + + @ParameterizedTest + @ValueSource(booleans={true, false}) + void testLoad(boolean found) { + Work work = EntityFactory.makeWork("SGP1"); + when(mockWorkRepo.getByWorkNumber(work.getWorkNumber())).thenReturn(work); + OperationType opType = EntityFactory.makeOperationType("opname", null); + when(mockOpTypeRepo.getByName(opType.getName())).thenReturn(opType); + String LP = "LP1"; + SlotCopyRecord record; + SlotCopySave save; + if (found) { + record = new SlotCopyRecord(); + record.setId(500); + save = new SlotCopySave(); + save.setOperationType(opType.getName()); + doReturn(save).when(service).reassembleSave(any()); + } else { + record = null; + save = null; + } + when(mockRecordRepo.findByOperationTypeAndWorkAndLpNumber(any(), any(), any())).thenReturn(Optional.ofNullable(record)); + + if (found) { + assertSame(save, service.load(opType.getName(), work.getWorkNumber(), LP)); + verify(service).reassembleSave(record); + } else { + assertThrows(EntityNotFoundException.class, () -> service.load(opType.getName(), work.getWorkNumber(), LP)); + verify(service, never()).reassembleSave(any()); + } + verify(mockRecordRepo).findByOperationTypeAndWorkAndLpNumber(opType, work, LP); + } + + @Test + void testReassembleSave() { + SlotCopyRecord record = new SlotCopyRecord(); + Work work = EntityFactory.makeWork("SGP1"); + OperationType opType = EntityFactory.makeOperationType("opname", null); + record.setWork(work); + record.setOperationType(opType); + record.setLpNumber("LP1"); + record.setNotes(List.of( + new SlotCopyRecordNote(NOTE_BARCODE, "STAN-A"), + new SlotCopyRecordNote(NOTE_LWTYPE, "lwtype"), + new SlotCopyRecordNote(NOTE_PREBARCODE, "pb"), + new SlotCopyRecordNote(NOTE_BIOSTATE, "bs"), + new SlotCopyRecordNote(NOTE_COSTING, "SGP"), + new SlotCopyRecordNote(NOTE_LOT, "lot1"), + new SlotCopyRecordNote(NOTE_PROBELOT, "probe1"), + new SlotCopyRecordNote(NOTE_EXECUTION, "manual"), + new SlotCopyRecordNote(NOTE_SRC_BARCODE, 0, "STAN-0"), + new SlotCopyRecordNote(NOTE_SRC_STATE, 0, "discarded"), + new SlotCopyRecordNote(NOTE_SRC_BARCODE, 1, "STAN-1"), + new SlotCopyRecordNote(NOTE_SRC_STATE, 1, "active"), + new SlotCopyRecordNote(NOTE_CON_SRCBC, 0,"STAN-0"), + new SlotCopyRecordNote(NOTE_CON_SRCADDRESS, 0, "A1"), + new SlotCopyRecordNote(NOTE_CON_DESTADDRESS, 0, "A2"), + new SlotCopyRecordNote(NOTE_CON_SRCBC, 1, "STAN-1"), + new SlotCopyRecordNote(NOTE_CON_SRCADDRESS, 1, "B1"), + new SlotCopyRecordNote(NOTE_CON_DESTADDRESS, 1, "B2") + )); + SlotCopySave save = service.reassembleSave(record); + + assertEquals("SGP1", save.getWorkNumber()); + assertEquals("opname", save.getOperationType()); + assertEquals("LP1", save.getLpNumber()); + assertEquals("STAN-A", save.getBarcode()); + assertEquals("lwtype", save.getLabwareType()); + assertEquals("pb", save.getPreBarcode()); + assertEquals("bs", save.getBioState()); + assertEquals(SlideCosting.SGP, save.getCosting()); + assertEquals("lot1", save.getLotNumber()); + assertEquals("probe1", save.getProbeLotNumber()); + assertEquals(ExecutionType.manual, save.getExecutionType()); + assertThat(save.getSources()).containsExactlyInAnyOrder(new SlotCopySource("STAN-0", Labware.State.discarded), + new SlotCopySource("STAN-1", Labware.State.active)); + assertThat(save.getContents()).containsExactlyInAnyOrder(new SlotCopyContent("STAN-0", new Address(1,1), new Address(1,2)), + new SlotCopyContent("STAN-1", new Address(2,1), new Address(2,2))); + } + + @ParameterizedTest + @CsvSource({",","'',", "'1',1"}) + void testNullableValueOf(String string, Integer expected) { + assertEquals(expected, nullableValueOf(string, Integer::valueOf)); + } + + @ParameterizedTest + @CsvSource({"' Alpha ',Alpha", "Beta,Beta", ",", "' ',"}) + void testTrimAndCheck(String string, String expected) { + List problems = new ArrayList<>(expected==null ? 1 : 0); + assertEquals(expected, service.trimAndCheck(problems, "thing", string)); + if (expected==null) { + assertThat(problems).containsExactly("Missing thing."); + } else { + assertThat(problems).isEmpty(); + } + } + + @ParameterizedTest + @CsvSource({"true,true,true", "false,true,true", "true,false,true", "true,true,false", "false,false,false"}) + void testCheckRequiredFields(boolean workPresent, boolean opTypePresent, boolean lpPresent) { + SlotCopySave request = new SlotCopySave(); + request.setWorkNumber(workPresent ? " SGP1 " : null); + request.setOperationType(opTypePresent ? " opname " : ""); + request.setLpNumber(lpPresent ? " LP1 " : " "); + List problems = new ArrayList<>(); + service.checkRequiredFields(problems, request); + assertEquals(workPresent ? "SGP1":null, request.getWorkNumber()); + assertEquals(opTypePresent ? "opname" : null, request.getOperationType()); + assertEquals(lpPresent ? "LP1" : null, request.getLpNumber()); + + if (workPresent && opTypePresent && lpPresent) { + assertThat(problems).isEmpty(); + } + if (!workPresent) { + assertThat(problems).contains("Missing work number."); + } + if (!opTypePresent) { + assertThat(problems).contains("Missing operation type."); + } + if (!lpPresent) { + assertThat(problems).contains("Missing LP number."); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false,true}) + void testMayAddNote(boolean valuePresent) { + List notes = new ArrayList<>(); + service.mayAddNote(notes, "name", valuePresent ? "value" : null); + if (valuePresent) { + assertThat(notes).containsExactly(new SlotCopyRecordNote("name", "value")); + } else { + assertThat(notes).isEmpty(); + } + } + + @Test + void testCreateNotes() { + SlotCopySave request = new SlotCopySave(); + request.setOperationType("opname"); + request.setWorkNumber("SGP1"); + request.setLpNumber("LP1"); + request.setCosting(SlideCosting.Faculty); + request.setExecutionType(ExecutionType.automated); + request.setBarcode("STAN-A"); + request.setBioState("bs"); + request.setLabwareType("lwtype"); + request.setPreBarcode("pb"); + request.setLotNumber("lot1"); + request.setProbeLotNumber("probe1"); + request.setSources(List.of(new SlotCopySource("STAN-0", Labware.State.discarded), + new SlotCopySource("STAN-1", Labware.State.active))); + request.setContents(List.of( + new SlotCopyContent("STAN-0", new Address(1, 1), new Address(1, 2)), + new SlotCopyContent("STAN-1", new Address(2, 1), new Address(2, 2)) + )); + List notes = service.createNotes(request); + assertThat(notes).containsExactlyInAnyOrder( + new SlotCopyRecordNote(NOTE_COSTING, "Faculty"), + new SlotCopyRecordNote(NOTE_EXECUTION, "automated"), + new SlotCopyRecordNote(NOTE_BARCODE, "STAN-A"), + new SlotCopyRecordNote(NOTE_BIOSTATE, "bs"), + new SlotCopyRecordNote(NOTE_LWTYPE, "lwtype"), + new SlotCopyRecordNote(NOTE_PREBARCODE, "pb"), + new SlotCopyRecordNote(NOTE_LOT, "lot1"), + new SlotCopyRecordNote(NOTE_PROBELOT, "probe1"), + new SlotCopyRecordNote(NOTE_SRC_BARCODE, 0, "STAN-0"), + new SlotCopyRecordNote(NOTE_SRC_STATE, 0, "discarded"), + new SlotCopyRecordNote(NOTE_SRC_BARCODE, 1, "STAN-1"), + new SlotCopyRecordNote(NOTE_SRC_STATE, 1, "active"), + new SlotCopyRecordNote(NOTE_CON_SRCBC, 0, "STAN-0"), + new SlotCopyRecordNote(NOTE_CON_SRCADDRESS, 0, "A1"), + new SlotCopyRecordNote(NOTE_CON_DESTADDRESS, 0, "A2"), + new SlotCopyRecordNote(NOTE_CON_SRCBC, 1, "STAN-1"), + new SlotCopyRecordNote(NOTE_CON_SRCADDRESS, 1, "B1"), + new SlotCopyRecordNote(NOTE_CON_DESTADDRESS, 1, "B2") + ); + } + + @Test + void testLoadNoteMap() { + List notes = List.of( + new SlotCopyRecordNote("Alpha", "Alabama"), + new SlotCopyRecordNote("Beta", "Banana"), + new SlotCopyRecordNote("Gamma", 2, "G2"), + new SlotCopyRecordNote("Gamma", 0, "G0") + ); + Map> map = service.loadNoteMap(notes); + assertThat(map).hasSize(3); + assertThat(map.get("Alpha")).containsExactly("Alabama"); + assertThat(map.get("Beta")).containsExactly("Banana"); + assertThat(map.get("Gamma")).containsExactly("G0", null, "G2"); + } + + @Test + void testSingleNoteValue() { + Map> map = Map.of("Alpha", List.of("Alabama"), "Beta", List.of()); + assertEquals("Alabama", service.singleNoteValue(map, "Alpha")); + assertNull(service.singleNoteValue(map, "Beta")); + assertNull(service.singleNoteValue(map, "Gamma")); + } +} \ No newline at end of file diff --git a/src/test/resources/graphql/reloadslotcopy.graphql b/src/test/resources/graphql/reloadslotcopy.graphql new file mode 100644 index 00000000..1237ebd7 --- /dev/null +++ b/src/test/resources/graphql/reloadslotcopy.graphql @@ -0,0 +1,17 @@ +query { + reloadSlotCopy(operationType: "opname", workNumber: "[WORK]", lpNumber: "LP1") { + operationType + barcode + workNumber + lpNumber + costing + executionType + preBarcode + labwareType + lotNumber + probeLotNumber + bioState + sources { barcode labwareState } + contents { sourceBarcode sourceAddress destinationAddress } + } +} \ No newline at end of file diff --git a/src/test/resources/graphql/saveslotcopy.graphql b/src/test/resources/graphql/saveslotcopy.graphql new file mode 100644 index 00000000..bbe036b5 --- /dev/null +++ b/src/test/resources/graphql/saveslotcopy.graphql @@ -0,0 +1,25 @@ +mutation { + saveSlotCopy(request: { + barcode: "STAN-A" + bioState: "bs" + costing: SGP + lotNumber: "lot1" + probeLotNumber: "probe1" + labwareType: "lt1" + operationType: "opname" + preBarcode: "pb1" + workNumber: "[WORK]" + lpNumber: "LP1" + executionType: manual + sources: [{barcode: "STAN-0", labwareState: active}] + contents: [ + {sourceBarcode: "STAN-0", sourceAddress: "A2", destinationAddress: "A1"} + {sourceBarcode: "STAN-1", sourceAddress: "A1", destinationAddress: "A2"} + ] + }) { + barcode + workNumber + lpNumber + operationType + } +} \ No newline at end of file From 27447eaa5321878fbf3f692b0ea4a00f7a3451ea Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:43:44 +0000 Subject: [PATCH 02/37] x1263 add priority field to flag --- .../sanger/sccp/stan/GraphQLDataFetchers.java | 6 +- .../sanger/sccp/stan/model/LabwareFlag.java | 24 ++++++- .../sanger/sccp/stan/request/FlagDetail.java | 20 +++++- .../sccp/stan/request/FlagLabwareRequest.java | 18 ++++- .../sccp/stan/request/LabwareFlagged.java | 18 +++-- .../stan/request/history/FlagBarcodes.java | 47 +++++++++++++ .../stan/request/{ => history}/History.java | 47 ++++++++----- .../request/{ => history}/HistoryEntry.java | 2 +- .../request/{ => history}/HistoryGraph.java | 2 +- .../extract/ExtractResultQueryService.java | 4 +- .../service/flag/FlagLabwareServiceImp.java | 10 ++- .../service/flag/FlagLookupServiceImp.java | 68 ++++++++++++++----- .../service/graph/BuchheimLayoutService.java | 4 +- .../graph/BuchheimLayoutServiceImp.java | 4 +- .../service/graph/GraphRenderService.java | 2 +- .../sccp/stan/service/graph/GraphService.java | 4 +- .../stan/service/graph/GraphServiceImp.java | 7 +- .../graph/render/GraphRendererImp.java | 6 +- .../stan/service/history/HistoryService.java | 2 +- .../service/history/HistoryServiceImp.java | 24 +++++-- .../operation/plan/PlanServiceImp.java | 4 +- .../resources/db/changelog/changelog-3.02.xml | 14 ++++ .../db/changelog/changelog-master.xml | 1 + src/main/resources/schema.graphqls | 22 +++++- .../integrationtest/TestHistoryQuery.java | 23 ++++++- .../integrationtest/TestLabwareFlags.java | 6 +- .../service/TestVisiumPermDataService.java | 2 +- .../TestExtractResultQueryService.java | 12 ++-- .../service/flag/TestFlagLabwareService.java | 24 ++++--- .../service/flag/TestFlagLookupService.java | 47 +++++++------ .../graph/TestBuchheimLayoutService.java | 4 +- .../service/graph/TestGraphRenderService.java | 2 +- .../stan/service/graph/TestGraphService.java | 7 +- .../graph/render/TestGraphRenderer.java | 6 +- .../service/history/TestHistoryService.java | 46 +++++++++---- .../operation/plan/TestPlanService.java | 11 +-- .../releasefile/TestReleaseFileService.java | 8 +-- .../resources/graphql/flaglabware.graphql | 1 + src/test/resources/graphql/history.graphql | 4 ++ .../resources/graphql/labwareflagged.graphql | 1 + 40 files changed, 409 insertions(+), 155 deletions(-) create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/request/history/FlagBarcodes.java rename src/main/java/uk/ac/sanger/sccp/stan/request/{ => history}/History.java (55%) rename src/main/java/uk/ac/sanger/sccp/stan/request/{ => history}/HistoryEntry.java (99%) rename src/main/java/uk/ac/sanger/sccp/stan/request/{ => history}/HistoryGraph.java (98%) create mode 100644 src/main/resources/db/changelog/changelog-3.02.xml diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLDataFetchers.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLDataFetchers.java index 24471783..3b0ecb3c 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLDataFetchers.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLDataFetchers.java @@ -13,6 +13,8 @@ import uk.ac.sanger.sccp.stan.repo.*; import uk.ac.sanger.sccp.stan.request.*; import uk.ac.sanger.sccp.stan.request.LabwareRoi.RoiResult; +import uk.ac.sanger.sccp.stan.request.history.History; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph; import uk.ac.sanger.sccp.stan.service.*; import uk.ac.sanger.sccp.stan.service.extract.ExtractResultQueryService; import uk.ac.sanger.sccp.stan.service.flag.FlagLookupService; @@ -214,10 +216,10 @@ public DataFetcher findLabwareFlagged() { throw new IllegalArgumentException("No barcode supplied."); } Labware lw = labwareRepo.getByBarcode(barcode); - if (requestsField(dfe, "flagged")) { + if (requestsField(dfe, "flagged") || requestsField(dfe, "flagPriority")) { return flagLookupService.getLabwareFlagged(lw); } - return new LabwareFlagged(lw, false); + return new LabwareFlagged(lw, null); }; } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareFlag.java b/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareFlag.java index f8e2a249..edb6cc0d 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareFlag.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareFlag.java @@ -11,6 +11,10 @@ */ @Entity public class LabwareFlag { + public enum Priority implements Comparable { + note, flag + } + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @@ -23,12 +27,17 @@ public class LabwareFlag { private User user; private Integer operationId; - public LabwareFlag(Integer id, Labware labware, String description, User user, Integer operationId) { + @Column(columnDefinition = "enum('note', 'flag')") + @Enumerated(EnumType.STRING) + private Priority priority = Priority.flag; + + public LabwareFlag(Integer id, Labware labware, String description, User user, Integer operationId, Priority priority) { this.id = id; this.labware = labware; this.description = description; this.user = user; this.operationId = operationId; + this.priority = priority; } public LabwareFlag() {} @@ -77,6 +86,14 @@ public void setOperationId(Integer operationId) { this.operationId = operationId; } + public Priority getPriority() { + return this.priority; + } + + public void setPriority(Priority priority) { + this.priority = priority; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -86,7 +103,9 @@ public boolean equals(Object o) { && Objects.equals(this.labware, that.labware) && Objects.equals(this.description, that.description) && Objects.equals(this.user, that.user) - && Objects.equals(this.operationId, that.operationId)); + && Objects.equals(this.operationId, that.operationId) + && this.priority==that.priority + ); } @Override @@ -102,6 +121,7 @@ public String toString() { .addRepr("description", description) .add("user", user==null ? null : user.getUsername()) .add("operationId", operationId) + .add("priority", priority) .toString(); } } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/FlagDetail.java b/src/main/java/uk/ac/sanger/sccp/stan/request/FlagDetail.java index 7a6d4ed4..0b042d96 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/FlagDetail.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/FlagDetail.java @@ -1,5 +1,7 @@ package uk.ac.sanger.sccp.stan.request; +import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; + import java.util.List; import java.util.Objects; @@ -17,10 +19,12 @@ public class FlagDetail { public static class FlagSummary { private String barcode; private String description; + private Priority priority; - public FlagSummary(String barcode, String description) { + public FlagSummary(String barcode, String description, Priority priority) { this.barcode = barcode; this.description = description; + this.priority = priority; } /** @@ -45,13 +49,23 @@ public void setDescription(String description) { this.description = description; } + public Priority getPriority() { + return this.priority; + } + + public void setPriority(Priority priority) { + this.priority = priority; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; FlagSummary that = (FlagSummary) o; return (Objects.equals(this.barcode, that.barcode) - && Objects.equals(this.description, that.description)); + && Objects.equals(this.description, that.description) + && this.priority == that.priority + ); } @Override @@ -61,7 +75,7 @@ public int hashCode() { @Override public String toString() { - return String.format("[%s: %s]", barcode, repr(description)); + return String.format("[%s: %s: %s]", priority, barcode, repr(description)); } } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/FlagLabwareRequest.java b/src/main/java/uk/ac/sanger/sccp/stan/request/FlagLabwareRequest.java index c4fd6c7c..721aa32e 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/FlagLabwareRequest.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/FlagLabwareRequest.java @@ -1,5 +1,6 @@ package uk.ac.sanger.sccp.stan.request; +import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; import uk.ac.sanger.sccp.utils.BasicUtils; import java.util.Objects; @@ -12,11 +13,13 @@ public class FlagLabwareRequest { private String barcode; private String description; private String workNumber; + private Priority priority; - public FlagLabwareRequest(String barcode, String description, String workNumber) { + public FlagLabwareRequest(String barcode, String description, String workNumber, Priority priority) { this.barcode = barcode; this.description = description; this.workNumber = workNumber; + this.priority = priority; } // required for framework @@ -53,6 +56,14 @@ public void setWorkNumber(String workNumber) { this.workNumber = workNumber; } + public Priority getPriority() { + return this.priority; + } + + public void setPriority(Priority priority) { + this.priority = priority; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -60,7 +71,9 @@ public boolean equals(Object o) { FlagLabwareRequest that = (FlagLabwareRequest) o; return (Objects.equals(this.barcode, that.barcode) && Objects.equals(this.description, that.description) - && Objects.equals(this.workNumber, that.workNumber)); + && Objects.equals(this.workNumber, that.workNumber) + && this.priority == that.priority + ); } @Override @@ -74,6 +87,7 @@ public String toString() { .add("barcode", barcode) .add("description", description) .addIfNotNull("workNumber", workNumber) + .add("priority", priority) .reprStringValues() .toString(); } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/LabwareFlagged.java b/src/main/java/uk/ac/sanger/sccp/stan/request/LabwareFlagged.java index 3ed499b5..30827dd1 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/LabwareFlagged.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/LabwareFlagged.java @@ -2,6 +2,7 @@ import org.jetbrains.annotations.NotNull; import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; import java.time.LocalDateTime; import java.util.List; @@ -15,11 +16,11 @@ public class LabwareFlagged { @NotNull private final Labware labware; - private final boolean flagged; + private final Priority flagPriority; - public LabwareFlagged(Labware labware, boolean flagged) { + public LabwareFlagged(Labware labware, Priority priority) { this.labware = requireNonNull(labware, "labware is null"); - this.flagged = flagged; + this.flagPriority = priority; } /** @@ -108,7 +109,12 @@ public Labware getLabware() { * Is there a labware flag applicable to this labware? */ public boolean isFlagged() { - return this.flagged; + return this.flagPriority != null; + } + + /** The highest priority of flag on the labware, if any */ + public Priority getFlagPriority() { + return this.flagPriority; } @Override @@ -116,7 +122,7 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; LabwareFlagged that = (LabwareFlagged) o; - return (this.flagged == that.flagged + return (this.flagPriority == that.flagPriority && this.labware.equals(that.labware)); } @@ -127,6 +133,6 @@ public int hashCode() { @Override public String toString() { - return String.format("LabwareFlagged(%s, %s)", labware.getBarcode(), flagged); + return String.format("LabwareFlagged(%s, %s)", labware.getBarcode(), flagPriority); } } \ No newline at end of file diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/history/FlagBarcodes.java b/src/main/java/uk/ac/sanger/sccp/stan/request/history/FlagBarcodes.java new file mode 100644 index 00000000..79c0a7ea --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/history/FlagBarcodes.java @@ -0,0 +1,47 @@ +package uk.ac.sanger.sccp.stan.request.history; + +import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; + +import java.util.List; +import java.util.Objects; + +/** + * A flagged labware barcode and its flag priority + * @author dr6 + */ +public class FlagBarcodes { + private final Priority priority; + private final List barcodes; + + public FlagBarcodes(Priority priority, List barcodes) { + this.priority = priority; + this.barcodes = barcodes; + } + + public Priority getPriority() { + return this.priority; + } + + public List getBarcodes() { + return this.barcodes; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FlagBarcodes that = (FlagBarcodes) o; + return (this.priority == that.priority + && Objects.equals(this.barcodes, that.barcodes)); + } + + @Override + public int hashCode() { + return Objects.hash(priority, barcodes); + } + + @Override + public String toString() { + return String.format("(%s: %s)", priority, barcodes); + } +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/History.java b/src/main/java/uk/ac/sanger/sccp/stan/request/history/History.java similarity index 55% rename from src/main/java/uk/ac/sanger/sccp/stan/request/History.java rename to src/main/java/uk/ac/sanger/sccp/stan/request/history/History.java index c7ebf8f5..995b3916 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/History.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/history/History.java @@ -1,13 +1,10 @@ -package uk.ac.sanger.sccp.stan.request; +package uk.ac.sanger.sccp.stan.request.history; -import uk.ac.sanger.sccp.stan.model.Labware; -import uk.ac.sanger.sccp.stan.model.Sample; -import uk.ac.sanger.sccp.utils.BasicUtils; +import uk.ac.sanger.sccp.stan.model.*; -import java.util.List; -import java.util.Objects; +import java.util.*; -import static uk.ac.sanger.sccp.utils.BasicUtils.nullToEmpty; +import static uk.ac.sanger.sccp.utils.BasicUtils.*; /** * @author dr6 @@ -16,13 +13,14 @@ public class History { private List entries; private List samples; private List labware; - private List flaggedBarcodes; + private Map> flagPriorityBarcodes; - public History(List entries, List samples, List labware, List flaggedBarcodes) { + public History(List entries, List samples, List labware, + Map> flagPriorityBarcodes) { setEntries(entries); setSamples(samples); setLabware(labware); - setFlaggedBarcodes(flaggedBarcodes); + setFlagPriorityBarcodes(flagPriorityBarcodes); } public History(List entries, List samples, List labware) { @@ -30,7 +28,7 @@ public History(List entries, List samples, List l } public History() { - this(null, null, null); + this(null, null, null, null); } public List getEntries() { @@ -57,12 +55,25 @@ public void setLabware(List labware) { this.labware = nullToEmpty(labware); } - public List getFlaggedBarcodes() { - return this.flaggedBarcodes; + public Map> getFlagPriorityBarcodes() { + return this.flagPriorityBarcodes; } - public void setFlaggedBarcodes(List flaggedBarcodes) { - this.flaggedBarcodes = nullToEmpty(flaggedBarcodes); + public void setFlagPriorityBarcodes(Map> flagPriorityBarcodes) { + this.flagPriorityBarcodes = nullToEmpty(flagPriorityBarcodes); + } + + /** + * Gets flagged barcodes as a list pairing up a priority with a list of barcodes + * @return a list of {@code FlagBarcodes} objects + */ + public List getFlagBarcodes() { + if (nullOrEmpty(flagPriorityBarcodes)) { + return List.of(); + } + return flagPriorityBarcodes.entrySet().stream() + .map(e -> new FlagBarcodes(e.getKey(), e.getValue())) + .toList(); } @Override @@ -73,7 +84,7 @@ public boolean equals(Object o) { return (Objects.equals(this.entries, that.entries) && Objects.equals(this.samples, that.samples) && Objects.equals(this.labware, that.labware) - && Objects.equals(this.flaggedBarcodes, that.flaggedBarcodes)); + && Objects.equals(this.flagPriorityBarcodes, that.flagPriorityBarcodes)); } @Override @@ -83,11 +94,11 @@ public int hashCode() { @Override public String toString() { - return BasicUtils.describe("History") + return describe("History") .add("entries", entries) .add("samples", samples) .add("labware", labware) - .add("flaggedBarcodes", flaggedBarcodes) + .add("flagPriorityBarcodes", flagPriorityBarcodes) .toString(); } } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/HistoryEntry.java b/src/main/java/uk/ac/sanger/sccp/stan/request/history/HistoryEntry.java similarity index 99% rename from src/main/java/uk/ac/sanger/sccp/stan/request/HistoryEntry.java rename to src/main/java/uk/ac/sanger/sccp/stan/request/history/HistoryEntry.java index e0051590..846654f0 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/HistoryEntry.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/history/HistoryEntry.java @@ -1,4 +1,4 @@ -package uk.ac.sanger.sccp.stan.request; +package uk.ac.sanger.sccp.stan.request.history; import uk.ac.sanger.sccp.stan.model.Operation; import uk.ac.sanger.sccp.utils.BasicUtils; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/HistoryGraph.java b/src/main/java/uk/ac/sanger/sccp/stan/request/history/HistoryGraph.java similarity index 98% rename from src/main/java/uk/ac/sanger/sccp/stan/request/HistoryGraph.java rename to src/main/java/uk/ac/sanger/sccp/stan/request/history/HistoryGraph.java index 6efb68ae..53d1dd59 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/HistoryGraph.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/history/HistoryGraph.java @@ -1,4 +1,4 @@ -package uk.ac.sanger.sccp.stan.request; +package uk.ac.sanger.sccp.stan.request.history; import java.time.LocalDateTime; import java.util.List; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/extract/ExtractResultQueryService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/extract/ExtractResultQueryService.java index 177e10eb..e650b292 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/extract/ExtractResultQueryService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/extract/ExtractResultQueryService.java @@ -66,7 +66,7 @@ public ExtractResult getExtractResult(String barcode, boolean loadFlags) { if (loadFlags) { lf = flagLookupService.getLabwareFlagged(lw); } else { - lf = new LabwareFlagged(lw, false); + lf = new LabwareFlagged(lw, null); } ExtractResult res = findExtractResult(lf); return res!=null ? res : new ExtractResult(lf, null, null); @@ -125,7 +125,7 @@ public ResultOp selectExtractResult(List labware) { List opIds = ops.stream().map(Operation::getId).collect(toList()); List resultOps = resultOpRepo.findAllByOperationIdIn(opIds).stream() .filter(ro -> slotIdSet.contains(ro.getSlotId())) - .collect(toList()); + .toList(); if (resultOps.isEmpty()) { return null; } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLabwareServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLabwareServiceImp.java index 8a2da1ae..d7be5445 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLabwareServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLabwareServiceImp.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; import uk.ac.sanger.sccp.stan.repo.*; import uk.ac.sanger.sccp.stan.request.FlagLabwareRequest; import uk.ac.sanger.sccp.stan.request.OperationResult; @@ -54,13 +55,16 @@ public OperationResult record(User user, FlagLabwareRequest request) throws Vali Labware lw = loadLabware(problems, request.getBarcode()); String description = checkDescription(problems, request.getDescription()); + if (request.getPriority()==null) { + problems.add("No priority specified."); + } OperationType opType = loadOpType(problems); if (!problems.isEmpty()) { throw new ValidationException(problems); } - return create(user, opType, lw, description, work); + return create(user, opType, lw, description, work, request.getPriority()); } /** @@ -131,9 +135,9 @@ OperationType loadOpType(Collection problems) { * @param work work to link to operation (or null) * @return the labware and operation */ - OperationResult create(User user, OperationType opType, Labware lw, String description, Work work) { + OperationResult create(User user, OperationType opType, Labware lw, String description, Work work, Priority priority) { Operation op = opService.createOperationInPlace(opType, user, lw, null, null); - LabwareFlag flag = new LabwareFlag(null, lw, description, user, op.getId()); + LabwareFlag flag = new LabwareFlag(null, lw, description, user, op.getId(), priority); flagRepo.save(flag); if (work!=null) { workService.link(work, List.of(op)); diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLookupServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLookupServiceImp.java index c29f867b..49ca38fd 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLookupServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLookupServiceImp.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; import uk.ac.sanger.sccp.stan.repo.LabwareFlagRepo; import uk.ac.sanger.sccp.stan.repo.OperationRepo; import uk.ac.sanger.sccp.stan.request.FlagDetail; @@ -20,13 +21,14 @@ import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toSet; import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; -import static uk.ac.sanger.sccp.utils.BasicUtils.stream; /** * @author dr6 */ @Service public class FlagLookupServiceImp implements FlagLookupService { + static final Comparator PRIORITY_COMPARATOR = Comparator.nullsFirst(Comparator.naturalOrder()); + private final Ancestoriser ancestoriser; private final LabwareFlagRepo flagRepo; private final OperationRepo opRepo; @@ -152,7 +154,18 @@ public List toDetails(UCMap> flagMap) { .collect(toList()); } - boolean isFlagged(Labware lw) { + Map opIdPriority(Collection flags) { + Map map = new HashMap<>(flags.size()); + for (LabwareFlag lf : flags) { + Integer opId = lf.getOperationId(); + if (PRIORITY_COMPARATOR.compare(map.get(opId), lf.getPriority()) < 0) { + map.put(opId, lf.getPriority()); + } + } + return map; + } + + Priority labwareFlagPriority(Labware lw) { requireNonNull(lw, "Labware is null"); Set slotSamples = SlotSample.stream(lw).collect(toSet()); Ancestry ancestry = ancestoriser.findAncestry(slotSamples); @@ -160,13 +173,22 @@ boolean isFlagged(Labware lw) { Set labwareIds = ancestorSS.stream().map(ss -> ss.slot().getLabwareId()).collect(toSet()); List flags = flagRepo.findAllByLabwareIdIn(labwareIds); if (flags.isEmpty()) { - return false; + return null; } - Set opIds = flags.stream().map(LabwareFlag::getOperationId).collect(toSet()); - Iterable ops = opRepo.findAllById(opIds); - return stream(ops).flatMap(op -> op.getActions().stream()) - .map(ac -> new SlotSample(ac.getDestination(), ac.getSample())) - .anyMatch(ancestorSS::contains); + Map opIdPriority = opIdPriority(flags); + Iterable ops = opRepo.findAllById(opIdPriority.keySet()); + Priority priority = null; + for (Operation op : ops) { + Priority opPriority = opIdPriority.get(op.getId()); + if (PRIORITY_COMPARATOR.compare(priority, opPriority) < 0) { + for (Action ac : op.getActions()) { + if (ancestorSS.contains(new SlotSample(ac.getDestination(), ac.getSample()))) { + priority = opPriority; + } + } + } + } + return priority; } @Override @@ -183,25 +205,37 @@ public List getLabwareFlagged(Collection labware) { Set labwareIds = ancestorSs.stream().map(ss -> ss.slot().getLabwareId()).collect(toSet()); List flags = flagRepo.findAllByLabwareIdIn(labwareIds); if (flags.isEmpty()) { - return labware.stream().map(lw -> new LabwareFlagged(lw, false)).toList(); + return labware.stream().map(lw -> new LabwareFlagged(lw, null)).toList(); } + Map opIdPriority = opIdPriority(flags); Set opIds = flags.stream().map(LabwareFlag::getOperationId).collect(toSet()); Iterable ops = opRepo.findAllById(opIds); - Set flaggedSlotSamples = stream(ops).flatMap(op -> op.getActions().stream()) - .map(ac -> new SlotSample(ac.getDestination(), ac.getSample())) - .collect(toSet()); + Map ssPriorities = new HashMap<>(); + for (Operation op : ops) { + Priority opPriority = opIdPriority.get(op.getId()); + for (Action ac : op.getActions()) { + SlotSample ss = new SlotSample(ac.getDestination(), ac.getSample()); + if (PRIORITY_COMPARATOR.compare(ssPriorities.get(ss), opPriority) < 0) { + ssPriorities.put(ss, opPriority); + } + } + } List lwFlagged = new ArrayList<>(labware.size()); for (Labware lw : labware) { - boolean flagged = (SlotSample.stream(lw).flatMap(ss -> ancestry.ancestors(ss).stream()) - .anyMatch(flaggedSlotSamples::contains)); - lwFlagged.add(new LabwareFlagged(lw, flagged)); + Priority priority = SlotSample.stream(lw) + .flatMap(ss -> ancestry.ancestors(ss).stream()) + .map(ssPriorities::get) + .filter(Objects::nonNull) + .max(Comparator.naturalOrder()) + .orElse(null); + lwFlagged.add(new LabwareFlagged(lw, priority)); } return lwFlagged; } @Override public LabwareFlagged getLabwareFlagged(Labware lw) { - return new LabwareFlagged(lw, isFlagged(lw)); + return new LabwareFlagged(lw, labwareFlagPriority(lw)); } /** @@ -212,7 +246,7 @@ public LabwareFlagged getLabwareFlagged(Labware lw) { */ FlagDetail toDetail(String barcode, List flags) { List summaries = flags.stream() - .map(flag -> new FlagSummary(flag.getLabware().getBarcode(), flag.getDescription())) + .map(flag -> new FlagSummary(flag.getLabware().getBarcode(), flag.getDescription(), flag.getPriority())) .collect(toList()); return new FlagDetail(barcode, summaries); } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/BuchheimLayoutService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/BuchheimLayoutService.java index 601b8c5e..232822e7 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/BuchheimLayoutService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/BuchheimLayoutService.java @@ -1,7 +1,7 @@ package uk.ac.sanger.sccp.stan.service.graph; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Link; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Node; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Link; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Node; import java.util.List; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/BuchheimLayoutServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/BuchheimLayoutServiceImp.java index 8daf967f..84f86709 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/BuchheimLayoutServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/BuchheimLayoutServiceImp.java @@ -2,8 +2,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Link; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Node; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Link; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Node; import java.time.LocalDateTime; import java.util.*; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphRenderService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphRenderService.java index 900409f6..622cced5 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphRenderService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphRenderService.java @@ -2,7 +2,7 @@ import org.springframework.stereotype.Service; import uk.ac.sanger.sccp.stan.request.GraphSVG; -import uk.ac.sanger.sccp.stan.request.HistoryGraph; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph; import uk.ac.sanger.sccp.stan.service.graph.render.*; import java.io.*; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphService.java index f62bc110..9be18223 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphService.java @@ -1,6 +1,8 @@ package uk.ac.sanger.sccp.stan.service.graph; -import uk.ac.sanger.sccp.stan.request.*; +import uk.ac.sanger.sccp.stan.request.GraphSVG; +import uk.ac.sanger.sccp.stan.request.history.History; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph; /** * Service to create history graphs. diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphServiceImp.java index 263c3f60..54169ab6 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/GraphServiceImp.java @@ -3,9 +3,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import uk.ac.sanger.sccp.stan.model.*; -import uk.ac.sanger.sccp.stan.request.*; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Link; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Node; +import uk.ac.sanger.sccp.stan.request.GraphSVG; +import uk.ac.sanger.sccp.stan.request.history.*; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Link; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Node; import uk.ac.sanger.sccp.stan.service.releasefile.Ancestoriser.SlotSample; import java.time.LocalDateTime; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/render/GraphRendererImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/render/GraphRendererImp.java index 101a958d..45be3abf 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/graph/render/GraphRendererImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/graph/render/GraphRendererImp.java @@ -1,8 +1,8 @@ package uk.ac.sanger.sccp.stan.service.graph.render; -import uk.ac.sanger.sccp.stan.request.HistoryGraph; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Link; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Node; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Link; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Node; import java.time.LocalDate; import java.util.*; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/history/HistoryService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/history/HistoryService.java index b4e784d1..a7e61c9d 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/history/HistoryService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/history/HistoryService.java @@ -1,6 +1,6 @@ package uk.ac.sanger.sccp.stan.service.history; -import uk.ac.sanger.sccp.stan.request.History; +import uk.ac.sanger.sccp.stan.request.history.History; import java.util.List; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/history/HistoryServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/history/HistoryServiceImp.java index bad30d6b..7a65b74e 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/history/HistoryServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/history/HistoryServiceImp.java @@ -5,7 +5,10 @@ import org.springframework.stereotype.Service; import uk.ac.sanger.sccp.stan.model.*; import uk.ac.sanger.sccp.stan.repo.*; -import uk.ac.sanger.sccp.stan.request.*; +import uk.ac.sanger.sccp.stan.request.LabwareFlagged; +import uk.ac.sanger.sccp.stan.request.SamplePositionResult; +import uk.ac.sanger.sccp.stan.request.history.History; +import uk.ac.sanger.sccp.stan.request.history.HistoryEntry; import uk.ac.sanger.sccp.stan.service.SlotRegionService; import uk.ac.sanger.sccp.stan.service.flag.FlagLookupService; import uk.ac.sanger.sccp.stan.service.history.ReagentActionDetailService.ReagentActionDetail; @@ -19,6 +22,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static java.util.stream.Collectors.toMap; import static java.util.stream.Collectors.*; import static uk.ac.sanger.sccp.utils.BasicUtils.*; @@ -129,7 +133,7 @@ public History getHistoryForEventType(String eventType) { } else { history = getHistoryForOpType(opTypeRepo.getByName(eventType)); } - history.setFlaggedBarcodes(loadFlaggedBarcodes(history.getLabware())); + history.setFlagPriorityBarcodes(loadFlaggedBarcodes(history.getLabware())); return history; } @@ -1075,13 +1079,19 @@ public List createEntriesForDestructions(Collection d } /** - * Gets a list of the barcodes of the indicated labware that are flagged + * Gets a list of the barcodes of the indicated labware that are flagged, mapped from flag priority * @param labware the labware - * @return the barcodes of those labware that are flagged + * @return the barcodes of those labware that are flagged for each priority */ - public List loadFlaggedBarcodes(Collection labware) { - List lfs = flagLookupService.getLabwareFlagged(labware); - return lfs.stream().filter(LabwareFlagged::isFlagged).map(lf -> lf.getLabware().getBarcode()).toList(); + public Map> loadFlaggedBarcodes(Collection labware) { + Map> priorityBarcodes = new EnumMap<>(LabwareFlag.Priority.class); + for (LabwareFlagged lf : flagLookupService.getLabwareFlagged(labware)) { + final LabwareFlag.Priority priority = lf.getFlagPriority(); + if (priority != null) { + priorityBarcodes.computeIfAbsent(priority, k -> new ArrayList<>()).add(lf.getBarcode()); + } + } + return priorityBarcodes; } /** diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/operation/plan/PlanServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/operation/plan/PlanServiceImp.java index e4d39fd5..2ed9fd01 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/operation/plan/PlanServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/operation/plan/PlanServiceImp.java @@ -210,9 +210,9 @@ public PlanData getPlanData(String barcode, boolean loadFlags) { lfSources = lf.subList(0, lf.size()-1); lfDest = lf.getLast(); } else { - lfDest = new LabwareFlagged(destination, false); + lfDest = new LabwareFlagged(destination, null); lfSources = sources.stream() - .map(lw -> new LabwareFlagged(lw, false)) + .map(lw -> new LabwareFlagged(lw, null)) .toList(); } return new PlanData(plan, lfSources, lfDest); diff --git a/src/main/resources/db/changelog/changelog-3.02.xml b/src/main/resources/db/changelog/changelog-3.02.xml new file mode 100644 index 00000000..49d9985d --- /dev/null +++ b/src/main/resources/db/changelog/changelog-3.02.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/changelog-master.xml b/src/main/resources/db/changelog/changelog-master.xml index 7b198c5e..a5ab7843 100644 --- a/src/main/resources/db/changelog/changelog-master.xml +++ b/src/main/resources/db/changelog/changelog-master.xml @@ -32,4 +32,5 @@ + diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index 139274a0..447220f9 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -69,6 +69,12 @@ enum ExecutionType { manual } +"""Priority of labware flags.""" +enum FlagPriority { + note + flag +} + """A user, who is associated with performing actions in the application.""" type User { username: String! @@ -261,6 +267,8 @@ type LabwareFlagged { created: Timestamp! """Is there a labware flag applicable to this labware?""" flagged: Boolean! + """The highest priority of flag on this labware, if any.""" + flagPriority: FlagPriority } """A solution used in an operation.""" @@ -1037,6 +1045,14 @@ type HistoryEntry { region: String } +"""A flag priority and a list of flagged barcodes.""" +type FlagBarcodes { + """The priority of flag linked to the barcodes.""" + priority: FlagPriority! + """The barcodes linked with the flags.""" + barcodes: [String!]! +} + """History as returned for a history query.""" type History { """The entries found for the history.""" @@ -1046,7 +1062,7 @@ type History { """The samples referenced by the entries.""" samples: [Sample!]! """The included labware barcodes that are flagged.""" - flaggedBarcodes: [String!]! + flagBarcodes: [FlagBarcodes!]! } """The SVG of a graph.""" @@ -1181,6 +1197,8 @@ type FlagSummary { barcode: String! """The description of the flag.""" description: String! + """The priority of the flag.""" + priority: FlagPriority! } """Information about flags related to some labware.""" @@ -1913,6 +1931,8 @@ input FlagLabwareRequest { description: String! """Work number to link to the flag.""" workNumber: String + """Priority of the flag.""" + priority: FlagPriority! } """Record the orientation state of labware.""" diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestHistoryQuery.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestHistoryQuery.java index 4c918e46..200ff3fa 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestHistoryQuery.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestHistoryQuery.java @@ -59,6 +59,9 @@ public void testHistory() throws Exception { String barcode = chainGet(lwData, "barcode"); int sampleId = chainGet(lwData, "slots", 0, "samples", 0, "id"); + entityCreator.createOpType("Flag labware", null, OperationTypeFlag.IN_PLACE); + recordFlag(barcode, work.getWorkNumber()); + String[] queryHeaders = { "historyForSampleId(sampleId: "+sampleId+")", "historyForLabwareBarcode(barcode: \""+barcode+"\")", @@ -76,7 +79,7 @@ public void testHistory() throws Exception { String queryName = queryHeader.substring(0, queryHeader.indexOf('(')); Map historyData = chainGet(response, "data", queryName); List> entries = chainGetList(historyData, "entries"); - assertThat(entries).hasSize(1); + assertThat(entries).hasSize(2); Map entry = entries.getFirst(); assertNotNull(entry.get("eventId")); assertEquals("Register", entry.get("type")); @@ -102,9 +105,27 @@ public void testHistory() throws Exception { assertEquals("Bone", chainGet(sampleData, "tissue", "spatialLocation", "tissueType", "name")); assertEquals("DONOR1", chainGet(sampleData, "tissue", "donor", "donorName")); assertNull(sampleData.get("section")); + + List> flagBcs = chainGet(historyData, "flagBarcodes"); + assertThat(flagBcs).hasSize(1); + Map flagBc = flagBcs.getFirst(); + assertEquals("flag", flagBc.get("priority")); + //noinspection unchecked + assertThat((List) flagBc.get("barcodes")).containsExactly(barcode); } } + private String recordFlag(String barcode, String workNumber) throws Exception { + String mutation = tester.readGraphQL("flaglabware.graphql"); + String desc = "bananas"; + mutation = mutation.replace("[BC]", barcode) + .replace("[WORKNUM]", workNumber) + .replace("[DESC]", desc); + Object response = tester.post(mutation); + assertNotNull(chainGet(response, "data", "flagLabware", "operations", 0, "id")); + return desc; + } + @Transactional @Test public void testHistoryForWorkNumber() throws Exception { diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestLabwareFlags.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestLabwareFlags.java index cc3b643c..a78a6e3b 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestLabwareFlags.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestLabwareFlags.java @@ -96,7 +96,9 @@ private void testLookUpFlags() throws Exception { query = tester.readGraphQL("labwareflagged.graphql"); response = tester.post(query); - assertEquals("STAN-1", chainGet(response, "data", "labwareFlagged", "barcode")); - assertEquals(Boolean.TRUE, chainGet(response, "data", "labwareFlagged", "flagged")); + Map lf = chainGet(response, "data", "labwareFlagged"); + assertEquals("STAN-1", lf.get("barcode")); + assertEquals(Boolean.TRUE, lf.get("flagged")); + assertEquals("flag", lf.get("flagPriority")); } } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/TestVisiumPermDataService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/TestVisiumPermDataService.java index bc6d3c77..10869b69 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/TestVisiumPermDataService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/TestVisiumPermDataService.java @@ -50,7 +50,7 @@ public void testLoad() { LabwareType lt = EntityFactory.makeLabwareType(3,2); Sample sample = EntityFactory.getSample(); Labware lw = EntityFactory.makeLabware(lt, sample, sample, sample); - LabwareFlagged lf = new LabwareFlagged(lw, false); + LabwareFlagged lf = new LabwareFlagged(lw, null); when(mockFlagLookupService.getLabwareFlagged(lw)).thenReturn(lf); final Slot slot = lw.getFirstSlot(); Ancestry ancestry = new Ancestry(); diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/extract/TestExtractResultQueryService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/extract/TestExtractResultQueryService.java index b4eaf5ae..bf1c5427 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/extract/TestExtractResultQueryService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/extract/TestExtractResultQueryService.java @@ -65,10 +65,10 @@ public void testGetExtractResult(boolean resultExists, Boolean flagged) { boolean loadFlags = (flagged!=null); LabwareFlagged lf; if (loadFlags) { - lf = new LabwareFlagged(lw, flagged); + lf = new LabwareFlagged(lw, flagged ? LabwareFlag.Priority.flag : null); when(mockFlagLookupService.getLabwareFlagged(lw)).thenReturn(lf); } else { - lf = new LabwareFlagged(lw, false); + lf = new LabwareFlagged(lw, null); } String barcode = lw.getBarcode(); when(mockLwRepo.getByBarcode(barcode)).thenReturn(lw); @@ -92,7 +92,7 @@ public void testGetExtractResult(boolean resultExists, Boolean flagged) { public void testFindExtractResult_foundOnLabware() { setupOpTypes(); Labware lw = EntityFactory.getTube(); - LabwareFlagged lf = new LabwareFlagged(lw, false); + LabwareFlagged lf = new LabwareFlagged(lw, null); ResultOp ro = new ResultOp(20, PassFail.pass, 30, 40, 50, 60); doReturn(ro).when(service).selectExtractResult(List.of(lw)); String conc = "20"; @@ -107,7 +107,7 @@ public void testFindExtractResult_foundOnLabware() { public void testFindExtractResult_noSources() { setupOpTypes(); Labware lw = EntityFactory.getTube(); - LabwareFlagged lf = new LabwareFlagged(lw, false); + LabwareFlagged lf = new LabwareFlagged(lw, null); doReturn(null).when(service).selectExtractResult(List.of(lw)); when(mockActionRepo.findSourceLabwareIdsForDestinationLabwareIds(any())).thenReturn(List.of()); assertNull(service.findExtractResult(lf)); @@ -121,7 +121,7 @@ public void testFindExtractResult_noSources() { public void testFindExtractResult_foundOnSources() { setupOpTypes(); Labware lw = EntityFactory.getTube(); - LabwareFlagged lf = new LabwareFlagged(lw, false); + LabwareFlagged lf = new LabwareFlagged(lw, null); final LabwareType lt = lw.getLabwareType(); doReturn(null).when(service).selectExtractResult(List.of(lw)); List sourceLabware = List.of(EntityFactory.makeEmptyLabware(lt), EntityFactory.makeEmptyLabware(lt)); @@ -146,7 +146,7 @@ public void testFindExtractResult_foundOnSources() { public void testFindExtractResult_notFoundOnSources() { setupOpTypes(); Labware lw = EntityFactory.getTube(); - LabwareFlagged lf = new LabwareFlagged(lw, false); + LabwareFlagged lf = new LabwareFlagged(lw, null); final LabwareType lt = lw.getLabwareType(); doReturn(null).when(service).selectExtractResult(any()); List sourceLabware = List.of(EntityFactory.makeEmptyLabware(lt), EntityFactory.makeEmptyLabware(lt)); diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLabwareService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLabwareService.java index 50e294d3..f83eaeef 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLabwareService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLabwareService.java @@ -7,6 +7,7 @@ import org.mockito.*; import uk.ac.sanger.sccp.stan.EntityFactory; import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; import uk.ac.sanger.sccp.stan.repo.*; import uk.ac.sanger.sccp.stan.request.FlagLabwareRequest; import uk.ac.sanger.sccp.stan.request.OperationResult; @@ -57,7 +58,7 @@ void testRecord_noRequest() { User user = EntityFactory.getUser(); assertValidationException(() -> service.record(user, null), List.of("No request supplied.")); - verify(service, never()).create(any(), any(), any(), any(), any()); + verify(service, never()).create(any(), any(), any(), any(), any(), any()); } @ParameterizedTest @@ -83,9 +84,9 @@ void testRecord_valid(boolean hasWork) { } OperationResult opres = new OperationResult(List.of(new Operation()), List.of(lw)); - doReturn(opres).when(service).create(any(), any(), any(), any(), any()); + doReturn(opres).when(service).create(any(), any(), any(), any(), any(), any()); - assertSame(opres, service.record(user, new FlagLabwareRequest(lw.getBarcode(), desc, workNumber))); + assertSame(opres, service.record(user, new FlagLabwareRequest(lw.getBarcode(), desc, workNumber, Priority.note))); verify(service).loadLabware(any(), eq(lw.getBarcode())); verify(service).checkDescription(any(), eq(desc)); @@ -95,7 +96,7 @@ void testRecord_valid(boolean hasWork) { } else { verifyNoInteractions(mockWorkService); } - verify(service).create(user, opType, lw, "Alpha beta gamma.", work); + verify(service).create(user, opType, lw, "Alpha beta gamma.", work, Priority.note); } @Test @@ -103,21 +104,21 @@ void testRecord_invalid() { when(mockLwRepo.findByBarcode(any())).thenReturn(Optional.empty()); when(mockOpTypeRepo.findByName(any())).thenReturn(Optional.empty()); - assertValidationException(() -> service.record(null, new FlagLabwareRequest("STAN-404", null, null)), + assertValidationException(() -> service.record(null, new FlagLabwareRequest("STAN-404", null, null, null)), List.of("No user specified.", "Unknown labware barcode: \"STAN-404\"", "Missing flag description.", - "Flag labware operation type is missing.")); + "Flag labware operation type is missing.", "No priority specified.")); verify(service).loadLabware(any(), eq("STAN-404")); verify(service).checkDescription(any(), isNull()); verify(service).loadOpType(any()); - verify(service, never()).create(any(), any(), any(), any(), any()); + verify(service, never()).create(any(), any(), any(), any(), any(), any()); } @Test void testRecord_badWork() { User user = EntityFactory.getUser(); Labware lw = EntityFactory.getTube(); - FlagLabwareRequest request = new FlagLabwareRequest(lw.getBarcode(), "flag desc", "SGP4"); + FlagLabwareRequest request = new FlagLabwareRequest(lw.getBarcode(), "flag desc", "SGP4", Priority.flag); when(mockLwRepo.findByBarcode(lw.getBarcode())).thenReturn(Optional.of(lw)); when(mockWorkService.validateUsableWork(any(), any())).then(addProblem("Bad work")); OperationType opType = EntityFactory.makeOperationType("Flag labware", null); @@ -128,7 +129,7 @@ void testRecord_badWork() { verify(service).checkDescription(any(), eq("flag desc")); verify(service).loadOpType(any()); verify(mockWorkService).validateUsableWork(any(), eq("SGP4")); - verify(service, never()).create(any(), any(), any(), any(), any()); + verify(service, never()).create(any(), any(), any(), any(), any(), any()); } @ParameterizedTest @@ -187,16 +188,17 @@ void testCreate(boolean hasWork) { User user = EntityFactory.getUser(); Work work = (hasWork ? EntityFactory.makeWork("SGP1") : null); String desc = "Alpha beta"; + Priority priority = Priority.flag; OperationType opType = EntityFactory.makeOperationType("Flag labware", null, OperationTypeFlag.IN_PLACE); Operation op = new Operation(); op.setId(500); when(mockOpService.createOperationInPlace(any(), any(), any(), any(), any())).thenReturn(op); - assertEquals(new OperationResult(List.of(op), List.of(lw)), service.create(user, opType, lw, desc, work)); + assertEquals(new OperationResult(List.of(op), List.of(lw)), service.create(user, opType, lw, desc, work, priority)); verify(mockOpService).createOperationInPlace(opType, user, lw, null, null); - verify(mockFlagRepo).save(new LabwareFlag(null, lw, desc, user, op.getId())); + verify(mockFlagRepo).save(new LabwareFlag(null, lw, desc, user, op.getId(), priority)); if (hasWork) { verify(mockWorkService).link(work, List.of(op)); } else { diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLookupService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLookupService.java index 78eeb2bd..b82203b6 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLookupService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLookupService.java @@ -6,6 +6,7 @@ import org.mockito.*; import uk.ac.sanger.sccp.stan.EntityFactory; import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; import uk.ac.sanger.sccp.stan.repo.LabwareFlagRepo; import uk.ac.sanger.sccp.stan.repo.OperationRepo; import uk.ac.sanger.sccp.stan.request.LabwareFlagged; @@ -23,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; /** Tests {@link FlagLookupServiceImp} */ class TestFlagLookupService { @@ -75,7 +77,7 @@ void testLookUp() { .map(slot -> new SlotSample(slot, sam)) .collect(toSet()); when(mockAncestry.keySet()).thenReturn(slotSamples); - LabwareFlag flag = new LabwareFlag(100, lw3, "flag alpha", null, 200); + LabwareFlag flag = new LabwareFlag(100, lw3, "flag alpha", null, 200, Priority.flag); Map> directFlags = Map.of(new SlotSample(lw3.getFirstSlot(), sam), List.of(flag)); doReturn(directFlags).when(service).loadDirectFlags(any()); @@ -130,11 +132,11 @@ void testLoadAncestry() { @Test void testLoadDirectFlags() { List labware = getLabware(2); - List lwIds = labware.stream().map(Labware::getId).collect(toList()); + List lwIds = labware.stream().map(Labware::getId).toList(); Set slotSamples = labware.stream().flatMap(SlotSample::stream).collect(toSet()); - List flags = List.of(new LabwareFlag(100, labware.get(0), "flag0", null, 200), - new LabwareFlag(101, labware.get(0), "flag1", null, 201), - new LabwareFlag(102, labware.get(1), "flag2", null, 202)); + List flags = List.of(new LabwareFlag(100, labware.get(0), "flag0", null, 200, Priority.flag), + new LabwareFlag(101, labware.get(0), "flag1", null, 201, Priority.flag), + new LabwareFlag(102, labware.get(1), "flag2", null, 202, Priority.flag)); when(mockFlagRepo.findAllByLabwareIdIn(any())).thenReturn(flags); Map> ssFlagMap = Map.of(slotSamples.iterator().next(), flags); doReturn(ssFlagMap).when(service).makeSsFlagMap(any(), any()); @@ -164,14 +166,14 @@ void testMakeSsFlagMap() { List slotSamples = labware.stream() .map(Labware::getFirstSlot) .map(slot -> new SlotSample(slot, slot.getSamples().get(0))) - .collect(toList()); + .toList(); List ops = labware.stream() .map(List::of) .map(lwList -> EntityFactory.makeOpForLabware(null, lwList, lwList)) .collect(toList()); when(mockOpRepo.findAllById(any())).thenReturn(ops); List flags = IntStream.range(0, labware.size()) - .mapToObj(i -> new LabwareFlag(100+i, labware.get(i), "flag"+i, null, ops.get(i).getId())) + .mapToObj(i -> new LabwareFlag(100+i, labware.get(i), "flag"+i, null, ops.get(i).getId(), Priority.flag)) .collect(toList()); Map> opIdLwIdMap = flags.stream() .collect(toMap(flag -> new OpIdLwId(flag.getOperationId(), flag.getLabware().getId()), List::of)); @@ -193,13 +195,13 @@ void testFlagsForLabware() { Sample sam1 = EntityFactory.getSample(); Sample sam2 = new Sample(sam1.getId()+1, null, sam1.getTissue(), sam1.getBioState()); Labware lw = EntityFactory.makeLabware(lt, sam1, sam2); - List lwSs = SlotSample.stream(lw).collect(toList()); + List lwSs = SlotSample.stream(lw).toList(); Labware other = EntityFactory.makeLabware(lt, sam1, sam2); SlotSample ss1 = new SlotSample(other.getFirstSlot(), sam1); SlotSample ss2 = new SlotSample(other.getSlots().get(1), sam2); SlotSample ss3 = new SlotSample(other.getFirstSlot(), sam2); List flags = IntStream.range(0,4) - .mapToObj(i -> new LabwareFlag(10+i, null, null, null, null)) + .mapToObj(i -> new LabwareFlag(10+i, null, null, null, null, Priority.flag)) .collect(toList()); Map> ssFlags = Map.of( ss1, List.of(flags.get(0), flags.get(1)), @@ -214,18 +216,19 @@ void testFlagsForLabware() { } @ParameterizedTest - @ValueSource(booleans={false,true}) - void testGetLabwareFlagged(boolean flagged) { + @ValueSource(strings={"", "note", "flag"}) + void testGetLabwareFlagged(String string) { + Priority priority = (nullOrEmpty(string) ? null : Priority.valueOf(string)); Labware lw = EntityFactory.getTube(); - doReturn(flagged).when(service).isFlagged(any()); + doReturn(priority).when(service).labwareFlagPriority(any()); LabwareFlagged lf = service.getLabwareFlagged(lw); assertSame(lw, lf.getLabware()); - assertEquals(flagged, lf.isFlagged()); - verify(service).isFlagged(lw); + assertEquals(priority, lf.getFlagPriority()); + verify(service).labwareFlagPriority(lw); } @Test - void testIsFlagged_noflags() { + void testLabwareFlagPriority_noflags() { Labware lw = EntityFactory.getTube(); final Sample sample = EntityFactory.getSample(); SlotSample lwSs = new SlotSample(lw.getFirstSlot(), sample); @@ -236,7 +239,7 @@ void testIsFlagged_noflags() { when(anc.keySet()).thenReturn(ancestorSS); when(mockFlagRepo.findAllByLabwareIdIn(any())).thenReturn(List.of()); - assertFalse(service.isFlagged(lw)); + assertNull(service.labwareFlagPriority(lw)); verify(mockAncestoriser).findAncestry(Set.of(lwSs)); verify(mockFlagRepo).findAllByLabwareIdIn(Set.of(lw.getId(), otherLw.getId())); @@ -245,7 +248,7 @@ void testIsFlagged_noflags() { @ParameterizedTest @ValueSource(booleans={false,true}) - void testIsFlagged(boolean relevant) { + void testLabwareFlagPriority(boolean relevant) { Labware lw = EntityFactory.getTube(); final Sample sample = EntityFactory.getSample(); SlotSample lwSs = new SlotSample(lw.getFirstSlot(), sample); @@ -255,8 +258,8 @@ void testIsFlagged(boolean relevant) { Set ancestorSS = Set.of(lwSs, new SlotSample(otherLw.getFirstSlot(), sample)); when(anc.keySet()).thenReturn(ancestorSS); List flags = List.of( - new LabwareFlag(100, otherLw, "Alpha", null, 10), - new LabwareFlag(101, otherLw, "Beta", null, 11) + new LabwareFlag(100, otherLw, "Alpha", null, 10, Priority.flag), + new LabwareFlag(101, otherLw, "Beta", null, 11, Priority.note) ); when(mockFlagRepo.findAllByLabwareIdIn(any())).thenReturn(flags); @@ -270,7 +273,7 @@ void testIsFlagged(boolean relevant) { when(mockOpRepo.findAllById(any())).thenReturn(List.of(op1, op2)); - assertEquals(relevant, service.isFlagged(lw)); + assertEquals(relevant ? Priority.note : null, service.labwareFlagPriority(lw)); verify(mockAncestoriser).findAncestry(Set.of(lwSs)); verify(mockFlagRepo).findAllByLabwareIdIn(Set.of(lw.getId(), otherLw.getId())); @@ -290,7 +293,7 @@ void testGetLabwareFlagged_multi() { SlotSample blockSs = new SlotSample(otherLw.getFirstSlot(), sample); Set ancSs = Set.of(lw1Ss, lw2Ss, blockSs); when(anc.keySet()).thenReturn(ancSs); - LabwareFlag flag = new LabwareFlag(100, otherLw, "Alpha", null, 10); + LabwareFlag flag = new LabwareFlag(100, otherLw, "Alpha", null, 10, Priority.flag); when(mockFlagRepo.findAllByLabwareIdIn(any())).thenReturn(List.of(flag)); Action ac = new Action(200, 10, otherLw.getFirstSlot(), otherLw.getFirstSlot(), sample, sample); @@ -300,7 +303,7 @@ void testGetLabwareFlagged_multi() { when(anc.ancestors(lw1Ss)).thenReturn(new LinkedHashSet<>(List.of(lw1Ss, blockSs))); when(anc.ancestors(lw2Ss)).thenReturn(new LinkedHashSet<>(List.of(lw2Ss))); - assertEquals(List.of(new LabwareFlagged(lw, true), new LabwareFlagged(lw2, false)), + assertEquals(List.of(new LabwareFlagged(lw, Priority.flag), new LabwareFlagged(lw2, null)), service.getLabwareFlagged(List.of(lw, lw2))); verify(mockAncestoriser).findAncestry(Set.of(lw1Ss, lw2Ss)); diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestBuchheimLayoutService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestBuchheimLayoutService.java index 0650e058..faa8ca9a 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestBuchheimLayoutService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestBuchheimLayoutService.java @@ -2,8 +2,8 @@ import org.junit.jupiter.api.*; import org.mockito.*; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Link; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Node; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Link; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Node; import java.time.LocalDateTime; import java.util.*; diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestGraphRenderService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestGraphRenderService.java index f9915dc7..d4e35a44 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestGraphRenderService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestGraphRenderService.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.Test; import uk.ac.sanger.sccp.stan.request.GraphSVG; -import uk.ac.sanger.sccp.stan.request.HistoryGraph; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph; import uk.ac.sanger.sccp.stan.service.graph.render.*; import java.io.PrintStream; diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestGraphService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestGraphService.java index 4f49b962..a523495b 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestGraphService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/graph/TestGraphService.java @@ -7,9 +7,10 @@ import org.mockito.*; import uk.ac.sanger.sccp.stan.EntityFactory; import uk.ac.sanger.sccp.stan.model.*; -import uk.ac.sanger.sccp.stan.request.*; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Link; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Node; +import uk.ac.sanger.sccp.stan.request.GraphSVG; +import uk.ac.sanger.sccp.stan.request.history.*; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Link; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Node; import uk.ac.sanger.sccp.stan.service.graph.GraphServiceImp.NodeData; import uk.ac.sanger.sccp.stan.service.graph.GraphServiceImp.NodeKey; import uk.ac.sanger.sccp.stan.service.releasefile.Ancestoriser.SlotSample; diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/graph/render/TestGraphRenderer.java b/src/test/java/uk/ac/sanger/sccp/stan/service/graph/render/TestGraphRenderer.java index 902f9861..1eaf35fe 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/graph/render/TestGraphRenderer.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/graph/render/TestGraphRenderer.java @@ -6,9 +6,9 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InOrder; import org.mockito.Mockito; -import uk.ac.sanger.sccp.stan.request.HistoryGraph; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Link; -import uk.ac.sanger.sccp.stan.request.HistoryGraph.Node; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Link; +import uk.ac.sanger.sccp.stan.request.history.HistoryGraph.Node; import java.time.LocalDate; import java.time.LocalDateTime; diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/history/TestHistoryService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/history/TestHistoryService.java index 027ed4ab..c48c79b2 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/history/TestHistoryService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/history/TestHistoryService.java @@ -8,7 +8,9 @@ import uk.ac.sanger.sccp.stan.EntityFactory; import uk.ac.sanger.sccp.stan.model.*; import uk.ac.sanger.sccp.stan.repo.*; -import uk.ac.sanger.sccp.stan.request.*; +import uk.ac.sanger.sccp.stan.request.LabwareFlagged; +import uk.ac.sanger.sccp.stan.request.SamplePositionResult; +import uk.ac.sanger.sccp.stan.request.history.*; import uk.ac.sanger.sccp.stan.service.SlotRegionService; import uk.ac.sanger.sccp.stan.service.flag.FlagLookupService; import uk.ac.sanger.sccp.stan.service.history.HistoryServiceImp.EventTypeFilter; @@ -217,18 +219,19 @@ public void testGetHistoryForWorkNumber() { doReturn(entries.subList(1,2)).when(service).createEntriesForOps(ops, null, lws, null, work.getWorkNumber()); doReturn(entries.subList(0,1)).when(service).createEntriesForReleases(releases, null, null, work.getWorkNumber()); - List flaggedBarcodes = List.of("alpha", "beta"); + Map> flaggedBcs = Map.of(LabwareFlag.Priority.flag, List.of("alpha", "beta"), + LabwareFlag.Priority.note, List.of("gamma")); List samples = List.of(sam1,sam2); List allLabware = BasicUtils.concat(lws, List.of(rlw1, rlw2)); doReturn(samples).when(service).referencedSamples(sameElements(entries, true), sameElements(allLabware, true)); - doReturn(flaggedBarcodes).when(service).loadFlaggedBarcodes(allLabware); + doReturn(flaggedBcs).when(service).loadFlaggedBarcodes(allLabware); History history = service.getHistoryForWorkNumber(workNumber); assertEquals(entries, history.getEntries()); assertEquals(samples, history.getSamples()); assertEquals(allLabware, history.getLabware()); - assertEquals(flaggedBarcodes, history.getFlaggedBarcodes()); + assertEquals(flaggedBcs, history.getFlagPriorityBarcodes()); } @ParameterizedTest @@ -407,14 +410,16 @@ public void testGetHistoryForEventType(String eventTypeName) { doReturn(history).when(service).getHistoryForOpType(opType); } - List flaggedBarcodes = List.of("alpha", "beta"); - doReturn(flaggedBarcodes).when(service).loadFlaggedBarcodes(history.getLabware()); + List flagBcs = List.of("alpha", "beta"); + final Map> priorityBcs = Map.of(LabwareFlag.Priority.flag, flagBcs); + doReturn(priorityBcs).when(service).loadFlaggedBarcodes(history.getLabware()); if (expectException) { assertThrows(EntityNotFoundException.class, () -> service.getHistoryForEventType(eventTypeName)); } else { assertSame(history, service.getHistoryForEventType(eventTypeName)); - assertEquals(flaggedBarcodes, history.getFlaggedBarcodes()); + assertEquals(priorityBcs, history.getFlagPriorityBarcodes()); + assertThat(history.getFlagBarcodes()).containsExactly(new FlagBarcodes(LabwareFlag.Priority.flag, flagBcs)); } } @@ -761,7 +766,12 @@ public void testGetHistoryForSamples() { when(mockDestructionRepo.findAllByLabwareIdIn(labwareIds)).thenReturn(destructions); when(mockReleaseRepo.findAllByLabwareIdIn(labwareIds)).thenReturn(releases); when(mockLwRepo.findAllByIdIn(labwareIds)).thenReturn(labware); - List flaggedBarcodes = List.of("Alpha", "Beta"); + final List flagBcs = List.of("Alpha", "Beta"); + final List noteBcs = List.of("Gamma"); + Map> flaggedBarcodes = Map.of( + LabwareFlag.Priority.flag, flagBcs, + LabwareFlag.Priority.note, noteBcs + ); doReturn(flaggedBarcodes).when(service).loadFlaggedBarcodes(labware); doReturn(labwareIds).when(service).loadLabwareIdsForOpsAndSampleIds(ops, sampleIds); @@ -776,7 +786,11 @@ public void testGetHistoryForSamples() { assertEquals(entries, history.getEntries()); assertEquals(samples, history.getSamples()); assertEquals(labware, history.getLabware()); - assertEquals(flaggedBarcodes, history.getFlaggedBarcodes()); + assertEquals(flaggedBarcodes, history.getFlagPriorityBarcodes()); + assertThat(history.getFlagBarcodes()).containsExactlyInAnyOrder( + new FlagBarcodes(LabwareFlag.Priority.flag, flagBcs), + new FlagBarcodes(LabwareFlag.Priority.note, noteBcs) + ); } @ParameterizedTest @@ -1109,8 +1123,8 @@ public void testLoadLabwareFlags() { verifyNoInteractions(mockFlagRepo); Labware lw = EntityFactory.getTube(); List flags = List.of( - new LabwareFlag(10, lw, "Alpha", null, 2), - new LabwareFlag(11, lw, "Beta", null, 3) + new LabwareFlag(10, lw, "Alpha", null, 2, LabwareFlag.Priority.flag), + new LabwareFlag(11, lw, "Beta", null, 3, LabwareFlag.Priority.flag) ); when(mockFlagRepo.findAllByOperationIdIn(List.of(2,3))).thenReturn(flags); @@ -1292,7 +1306,7 @@ public void testCreateEntriesForOps() { ); doReturn(opStainTypes).when(mockStainTypeRepo).loadOperationStainTypes(any()); - Map> opFlags = Map.of(opIds[0], List.of(new LabwareFlag(100, labware[0], "Alpha", null, opIds[0]))); + Map> opFlags = Map.of(opIds[0], List.of(new LabwareFlag(100, labware[0], "Alpha", null, opIds[0], LabwareFlag.Priority.flag))); doReturn(opFlags).when(service).loadLabwareFlags(any()); Map> radMap = Map.of(opIds[0], @@ -1603,9 +1617,13 @@ public void testLoadFlaggedBarcodes() { createLabware(); List labwares = Arrays.asList(this.labware); List lfs = IntStream.range(0, labwares.size()) - .mapToObj(i -> new LabwareFlagged(labwares.get(i), i==1 || i==2)) + .mapToObj(i -> new LabwareFlagged(labwares.get(i), + i==1 || i==2 ? LabwareFlag.Priority.flag : i==3 ? LabwareFlag.Priority.note : null)) .toList(); when(mockFlagLookupService.getLabwareFlagged(labwares)).thenReturn(lfs); - assertThat(service.loadFlaggedBarcodes(labwares)).containsExactlyInAnyOrder(labwares.get(1).getBarcode(), labwares.get(2).getBarcode()); + var map = service.loadFlaggedBarcodes(labwares); + assertThat(map).hasSize(2); + assertThat(map.get(LabwareFlag.Priority.flag)).containsExactlyInAnyOrder(labwares.get(1).getBarcode(), labwares.get(2).getBarcode()); + assertThat(map.get(LabwareFlag.Priority.note)).containsExactly(labwares.get(3).getBarcode()); } } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/operation/plan/TestPlanService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/operation/plan/TestPlanService.java index d5b77a60..81a28eef 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/operation/plan/TestPlanService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/operation/plan/TestPlanService.java @@ -362,8 +362,8 @@ public void testGetPlanData(int numPlansFound) { PlanOperation plan = plans.getFirst(); List sources = List.of(EntityFactory.makeEmptyLabware(lt)); doReturn(sources).when(planService).getSources(any()); - List lfSources = sources.stream().map(x -> new LabwareFlagged(x, false)).toList(); - LabwareFlagged lfDest = new LabwareFlagged(lw, false); + List lfSources = sources.stream().map(x -> new LabwareFlagged(x, null)).toList(); + LabwareFlagged lfDest = new LabwareFlagged(lw, null); assertEquals(new PlanData(plan, lfSources, lfDest), planService.getPlanData(barcode, false)); verify(planService).getSources(plan); @@ -378,6 +378,7 @@ public void testGetPlanData(int numPlansFound) { @ParameterizedTest @ValueSource(booleans={false,true}) public void testGetPlanData_flags(boolean flagged) { + LabwareFlag.Priority priority = (flagged ? LabwareFlag.Priority.flag : null); LabwareType lt = EntityFactory.getTubeType(); Labware lw = EntityFactory.makeEmptyLabware(lt); final String barcode = lw.getBarcode(); @@ -389,12 +390,12 @@ public void testGetPlanData_flags(boolean flagged) { when(mockPlanRepo.findAllByDestinationIdIn(any())).thenReturn(List.of(plan)); List sources = List.of(EntityFactory.makeEmptyLabware(lt)); doReturn(sources).when(planService).getSources(any()); - List lfSources = sources.stream().map(x -> new LabwareFlagged(x, flagged)).toList(); - LabwareFlagged lfDest = new LabwareFlagged(lw, flagged); + List lfSources = sources.stream().map(x -> new LabwareFlagged(x, priority)).toList(); + LabwareFlagged lfDest = new LabwareFlagged(lw, priority); when(mockFlagLookupService.getLabwareFlagged(anyCollection())).then(invocation -> { Collection lws = invocation.getArgument(0); - return lws.stream().map(x -> new LabwareFlagged(x, flagged)).toList(); + return lws.stream().map(x -> new LabwareFlagged(x, priority)).toList(); }); assertEquals(new PlanData(plan, lfSources, lfDest), planService.getPlanData(barcode, true)); diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/releasefile/TestReleaseFileService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/releasefile/TestReleaseFileService.java index 55a679ad..5e3c1d6b 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/releasefile/TestReleaseFileService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/releasefile/TestReleaseFileService.java @@ -1405,8 +1405,8 @@ public void testLoadFlags(boolean anyFlags) { List details; if (anyFlags) { details = List.of(new FlagDetail(lws.getFirst().getBarcode(), List.of( - new FlagDetail.FlagSummary("STAN-1", "Flag 1"), - new FlagDetail.FlagSummary("STAN-2", "Flag 2")))); + new FlagDetail.FlagSummary("STAN-1", "Flag 1", LabwareFlag.Priority.note), + new FlagDetail.FlagSummary("STAN-2", "Flag 2", LabwareFlag.Priority.flag)))); } else { details = List.of(); } @@ -1440,8 +1440,8 @@ public void testDescribeFlags(boolean anyFlags) { List summaries; if (anyFlags) { summaries = List.of( - new FlagDetail.FlagSummary("STAN-1", "Flag 1."), - new FlagDetail.FlagSummary("STAN-2", "Flag 2.") + new FlagDetail.FlagSummary("STAN-1", "Flag 1.", LabwareFlag.Priority.note), + new FlagDetail.FlagSummary("STAN-2", "Flag 2.", LabwareFlag.Priority.flag) ); } else { summaries = List.of(); diff --git a/src/test/resources/graphql/flaglabware.graphql b/src/test/resources/graphql/flaglabware.graphql index 47177a74..fac11753 100644 --- a/src/test/resources/graphql/flaglabware.graphql +++ b/src/test/resources/graphql/flaglabware.graphql @@ -3,6 +3,7 @@ mutation { barcode: "[BC]" description: "[DESC]" workNumber: "[WORKNUM]" + priority: flag }) { operations { id } } diff --git a/src/test/resources/graphql/history.graphql b/src/test/resources/graphql/history.graphql index c1f25493..354156aa 100644 --- a/src/test/resources/graphql/history.graphql +++ b/src/test/resources/graphql/history.graphql @@ -25,5 +25,9 @@ query { } section } + flagBarcodes { + priority + barcodes + } } } \ No newline at end of file diff --git a/src/test/resources/graphql/labwareflagged.graphql b/src/test/resources/graphql/labwareflagged.graphql index 84af477d..447a88f3 100644 --- a/src/test/resources/graphql/labwareflagged.graphql +++ b/src/test/resources/graphql/labwareflagged.graphql @@ -2,5 +2,6 @@ query { labwareFlagged(barcode: "STAN-1") { barcode flagged + flagPriority } } \ No newline at end of file From 8b1c7f624034bca153826a15872eb711bf690fc0 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 24 Jan 2025 15:49:20 +0000 Subject: [PATCH 03/37] x1257 increase capacity of slot_copy_record_note.value column --- src/main/resources/db/changelog/changelog-3.30.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/changelog/changelog-3.30.xml b/src/main/resources/db/changelog/changelog-3.30.xml index 4611f018..f335fc50 100644 --- a/src/main/resources/db/changelog/changelog-3.30.xml +++ b/src/main/resources/db/changelog/changelog-3.30.xml @@ -49,7 +49,7 @@ - + From b3bfd85f3f0c74017cad75de3653caf917a80d7d Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 24 Jan 2025 15:58:51 +0000 Subject: [PATCH 04/37] x1257 don't assume there are sources when reloading slot copy record --- .../service/SlotCopyRecordServiceImp.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordServiceImp.java index 695f088c..412ded88 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyRecordServiceImp.java @@ -105,17 +105,21 @@ SlotCopySave reassembleSave(SlotCopyRecord record) { save.setExecutionType(nullableValueOf(singleNoteValue(noteMap, NOTE_EXECUTION), ExecutionType::valueOf)); List sourceBarcodes = noteMap.get(NOTE_SRC_BARCODE); List sourceStates = noteMap.get(NOTE_SRC_STATE); - save.setSources(Zip.map(sourceBarcodes.stream(), sourceStates.stream(), - (bc, state) -> new SlotCopySource(bc, nullableValueOf(state, Labware.State::valueOf)) - ).toList()); + if (!nullOrEmpty(sourceBarcodes) && !nullOrEmpty(sourceStates)) { + save.setSources(Zip.map(sourceBarcodes.stream(), sourceStates.stream(), + (bc, state) -> new SlotCopySource(bc, nullableValueOf(state, Labware.State::valueOf)) + ).toList()); + } List contentSourceBarcodes = noteMap.get(NOTE_CON_SRCBC); List contentSourceAddress = noteMap.get(NOTE_CON_SRCADDRESS); List contentDestAddress = noteMap.get(NOTE_CON_DESTADDRESS); - save.setContents(IntStream.range(0, contentSourceBarcodes.size()).mapToObj( - i -> new SlotCopyContent(contentSourceBarcodes.get(i), - nullableValueOf(contentSourceAddress.get(i), Address::valueOf), - nullableValueOf(contentDestAddress.get(i), Address::valueOf)) - ).toList()); + if (!nullOrEmpty(contentSourceBarcodes)) { + save.setContents(IntStream.range(0, contentSourceBarcodes.size()).mapToObj( + i -> new SlotCopyContent(contentSourceBarcodes.get(i), + nullableValueOf(contentSourceAddress.get(i), Address::valueOf), + nullableValueOf(contentDestAddress.get(i), Address::valueOf)) + ).toList()); + } return save; } From f5ca29699d018e101ab022ae0211c67dba18923d Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 5 Feb 2025 14:41:02 +0000 Subject: [PATCH 05/37] x1263 Accept multiple barcodes in FlagLabwareRequest --- .../sccp/stan/request/FlagLabwareRequest.java | 25 ++-- .../service/flag/FlagLabwareServiceImp.java | 67 ++++++---- src/main/resources/schema.graphqls | 4 +- .../service/flag/TestFlagLabwareService.java | 123 ++++++++++++------ .../resources/graphql/flaglabware.graphql | 2 +- 5 files changed, 140 insertions(+), 81 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/FlagLabwareRequest.java b/src/main/java/uk/ac/sanger/sccp/stan/request/FlagLabwareRequest.java index 721aa32e..699e0bfa 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/FlagLabwareRequest.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/FlagLabwareRequest.java @@ -3,20 +3,23 @@ import uk.ac.sanger.sccp.stan.model.LabwareFlag.Priority; import uk.ac.sanger.sccp.utils.BasicUtils; +import java.util.List; import java.util.Objects; +import static uk.ac.sanger.sccp.utils.BasicUtils.nullToEmpty; + /** * Raise a flag on a piece of labware. * @author dr6 */ public class FlagLabwareRequest { - private String barcode; + private List barcodes = List.of(); private String description; private String workNumber; private Priority priority; - public FlagLabwareRequest(String barcode, String description, String workNumber, Priority priority) { - this.barcode = barcode; + public FlagLabwareRequest(List barcodes, String description, String workNumber, Priority priority) { + setBarcodes(barcodes); this.description = description; this.workNumber = workNumber; this.priority = priority; @@ -26,14 +29,14 @@ public FlagLabwareRequest(String barcode, String description, String workNumber, public FlagLabwareRequest() {} /** - * The barcode of the flagged labware. + * The barcodes of the flagged labware. */ - public String getBarcode() { - return this.barcode; + public List getBarcodes() { + return this.barcodes; } - public void setBarcode(String barcode) { - this.barcode = barcode; + public void setBarcodes(List barcodes) { + this.barcodes = nullToEmpty(barcodes); } /** @@ -69,7 +72,7 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; FlagLabwareRequest that = (FlagLabwareRequest) o; - return (Objects.equals(this.barcode, that.barcode) + return (Objects.equals(this.barcodes, that.barcodes) && Objects.equals(this.description, that.description) && Objects.equals(this.workNumber, that.workNumber) && this.priority == that.priority @@ -78,13 +81,13 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(barcode, description); + return Objects.hash(barcodes, description); } @Override public String toString() { return BasicUtils.describe("FlagLabwareRequest") - .add("barcode", barcode) + .add("barcodes", barcodes) .add("description", description) .addIfNotNull("workNumber", workNumber) .add("priority", priority) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLabwareServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLabwareServiceImp.java index d7be5445..63d81bcf 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLabwareServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/flag/FlagLabwareServiceImp.java @@ -10,11 +10,12 @@ import uk.ac.sanger.sccp.stan.service.OperationService; import uk.ac.sanger.sccp.stan.service.ValidationException; import uk.ac.sanger.sccp.stan.service.work.WorkService; +import uk.ac.sanger.sccp.utils.BasicUtils; import java.util.*; +import static java.util.stream.Collectors.toSet; import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; -import static uk.ac.sanger.sccp.utils.BasicUtils.repr; /** * @author dr6 @@ -53,7 +54,7 @@ public OperationResult record(User user, FlagLabwareRequest request) throws Vali Work work = (nullOrEmpty(request.getWorkNumber()) ? null : workService.validateUsableWork(problems, request.getWorkNumber())); - Labware lw = loadLabware(problems, request.getBarcode()); + List lws = loadLabware(problems, request.getBarcodes()); String description = checkDescription(problems, request.getDescription()); if (request.getPriority()==null) { problems.add("No priority specified."); @@ -64,30 +65,42 @@ public OperationResult record(User user, FlagLabwareRequest request) throws Vali throw new ValidationException(problems); } - return create(user, opType, lw, description, work, request.getPriority()); + return create(user, opType, lws, description, work, request.getPriority()); } /** * Loads the labware indicated. The labware does not need to be active to be flagged. * @param problems receptacle for problems - * @param barcode the barcode of the labware to load + * @param barcodes the barcodes of the labware to load * @return the labware found, or null */ - Labware loadLabware(Collection problems, String barcode) { - if (nullOrEmpty(barcode)) { - problems.add("No labware barcode supplied."); + List loadLabware(Collection problems, List barcodes) { + if (nullOrEmpty(barcodes)) { + problems.add("No labware barcodes supplied."); return null; } - var opt = lwRepo.findByBarcode(barcode); - if (opt.isEmpty()) { - problems.add("Unknown labware barcode: "+repr(barcode)); + if (barcodes.stream().anyMatch(BasicUtils::nullOrEmpty)) { + problems.add("Barcodes array has missing elements."); return null; } - Labware lw = opt.get(); - if (lw.isEmpty()) { - problems.add("Labware "+lw.getBarcode()+" is empty."); + List labware = lwRepo.findByBarcodeIn(barcodes); + Set foundBarcodes = labware.stream().map(lw -> lw.getBarcode().toUpperCase()) + .collect(toSet()); + List missing = barcodes.stream() + .filter(bc -> !foundBarcodes.contains(bc.toUpperCase())) + .map(BasicUtils::repr) + .toList(); + if (!missing.isEmpty()) { + problems.add("Unknown labware barcode: "+missing); } - return opt.get(); + List bcOfEmptyLabware = labware.stream() + .filter(Labware::isEmpty) + .map(Labware::getBarcode) + .toList(); + if (!bcOfEmptyLabware.isEmpty()) { + problems.add("Labware is empty: "+bcOfEmptyLabware); + } + return labware; } /** @@ -127,21 +140,27 @@ OperationType loadOpType(Collection problems) { } /** - * Records a flag labware operation and records the specified flag + * Records flag labware operations and records the specified flag * @param user the user responsible * @param opType the operation type to record - * @param lw the labware being flagged + * @param labware the labware being flagged * @param description the flag description - * @param work work to link to operation (or null) - * @return the labware and operation + * @param work work to link to operations (or null) + * @return the labware and operations */ - OperationResult create(User user, OperationType opType, Labware lw, String description, Work work, Priority priority) { - Operation op = opService.createOperationInPlace(opType, user, lw, null, null); - LabwareFlag flag = new LabwareFlag(null, lw, description, user, op.getId(), priority); - flagRepo.save(flag); + OperationResult create(User user, OperationType opType, List labware, String description, Work work, Priority priority) { + List ops = new ArrayList<>(labware.size()); + List flags = new ArrayList<>(labware.size()); + for (Labware lw : labware) { + Operation op = opService.createOperationInPlace(opType, user, lw, null, null); + LabwareFlag flag = new LabwareFlag(null, lw, description, user, op.getId(), priority); + ops.add(op); + flags.add(flag); + } + flagRepo.saveAll(flags); if (work!=null) { - workService.link(work, List.of(op)); + workService.link(work, ops); } - return new OperationResult(List.of(op), List.of(lw)); + return new OperationResult(ops, labware); } } diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index 447220f9..ed60e6ef 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -1925,8 +1925,8 @@ input QCLabwareRequest { """Raise a flag on a piece of labware.""" input FlagLabwareRequest { - """The barcode of the flagged labware.""" - barcode: String! + """The barcodes of the flagged labware.""" + barcodes: [String!]! """The description of the flag.""" description: String! """Work number to link to the flag.""" diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLabwareService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLabwareService.java index f83eaeef..d0047950 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLabwareService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/flag/TestFlagLabwareService.java @@ -13,11 +13,12 @@ import uk.ac.sanger.sccp.stan.request.OperationResult; import uk.ac.sanger.sccp.stan.service.OperationService; import uk.ac.sanger.sccp.stan.service.work.WorkService; +import uk.ac.sanger.sccp.utils.Zip; import java.util.*; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import static uk.ac.sanger.sccp.stan.Matchers.*; @@ -65,13 +66,17 @@ void testRecord_noRequest() { @ValueSource(booleans={false,true}) void testRecord_valid(boolean hasWork) { User user = EntityFactory.getUser(); - Labware lw = EntityFactory.getTube(); + Labware lw1 = EntityFactory.getTube(); + Labware lw2 = EntityFactory.makeLabware(lw1.getLabwareType(), lw1.getFirstSlot().getSamples().getFirst()); + List labware = List.of(lw1, lw2); + List barcodes = labware.stream().map(Labware::getBarcode).toList(); Work work = hasWork ? EntityFactory.makeWork("SGP11") : null; String workNumber = work==null ? null : work.getWorkNumber(); String desc = " Alpha beta gamma. "; - when(mockLwRepo.findByBarcode(lw.getBarcode())).thenReturn(Optional.of(lw)); - final Integer flagId = 500; + when(mockLwRepo.findByBarcodeIn(any())).thenReturn(labware); + int[] flagIdCounter = {500}; when(mockFlagRepo.save(any())).then(invocation -> { + int flagId = flagIdCounter[0]++; LabwareFlag flag = invocation.getArgument(0); flag.setId(flagId); return flag; @@ -83,12 +88,12 @@ void testRecord_valid(boolean hasWork) { when(mockWorkService.validateUsableWork(any(), any())).thenReturn(work); } - OperationResult opres = new OperationResult(List.of(new Operation()), List.of(lw)); + OperationResult opres = new OperationResult(List.of(new Operation()), labware); doReturn(opres).when(service).create(any(), any(), any(), any(), any(), any()); - assertSame(opres, service.record(user, new FlagLabwareRequest(lw.getBarcode(), desc, workNumber, Priority.note))); + assertSame(opres, service.record(user, new FlagLabwareRequest(barcodes, desc, workNumber, Priority.note))); - verify(service).loadLabware(any(), eq(lw.getBarcode())); + verify(service).loadLabware(any(), eq(barcodes)); verify(service).checkDescription(any(), eq(desc)); verify(service).loadOpType(any()); if (hasWork) { @@ -96,19 +101,21 @@ void testRecord_valid(boolean hasWork) { } else { verifyNoInteractions(mockWorkService); } - verify(service).create(user, opType, lw, "Alpha beta gamma.", work, Priority.note); + verify(service).create(user, opType, labware, "Alpha beta gamma.", work, Priority.note); } @Test void testRecord_invalid() { - when(mockLwRepo.findByBarcode(any())).thenReturn(Optional.empty()); + Labware lw = EntityFactory.getTube(); + when(mockLwRepo.findByBarcodeIn(any())).thenReturn(List.of(lw)); when(mockOpTypeRepo.findByName(any())).thenReturn(Optional.empty()); - - assertValidationException(() -> service.record(null, new FlagLabwareRequest("STAN-404", null, null, null)), - List.of("No user specified.", "Unknown labware barcode: \"STAN-404\"", "Missing flag description.", + List barcodes = List.of(lw.getBarcode().toLowerCase(), "STAN-404"); + assertValidationException(() -> service.record(null, new FlagLabwareRequest(barcodes, + null, null, null)), + List.of("No user specified.", "Unknown labware barcode: [\"STAN-404\"]", "Missing flag description.", "Flag labware operation type is missing.", "No priority specified.")); - verify(service).loadLabware(any(), eq("STAN-404")); + verify(service).loadLabware(any(), eq(barcodes)); verify(service).checkDescription(any(), isNull()); verify(service).loadOpType(any()); verify(service, never()).create(any(), any(), any(), any(), any(), any()); @@ -118,14 +125,15 @@ void testRecord_invalid() { void testRecord_badWork() { User user = EntityFactory.getUser(); Labware lw = EntityFactory.getTube(); - FlagLabwareRequest request = new FlagLabwareRequest(lw.getBarcode(), "flag desc", "SGP4", Priority.flag); - when(mockLwRepo.findByBarcode(lw.getBarcode())).thenReturn(Optional.of(lw)); + List barcodes = List.of(lw.getBarcode()); + FlagLabwareRequest request = new FlagLabwareRequest(barcodes, "flag desc", "SGP4", Priority.flag); + when(mockLwRepo.findByBarcodeIn(barcodes)).thenReturn(List.of(lw)); when(mockWorkService.validateUsableWork(any(), any())).then(addProblem("Bad work")); OperationType opType = EntityFactory.makeOperationType("Flag labware", null); when(mockOpTypeRepo.findByName(opType.getName())).thenReturn(Optional.of(opType)); assertValidationException(() -> service.record(user, request), List.of("Bad work")); - verify(service).loadLabware(any(), eq(lw.getBarcode())); + verify(service).loadLabware(any(), eq(barcodes)); verify(service).checkDescription(any(), eq("flag desc")); verify(service).loadOpType(any()); verify(mockWorkService).validateUsableWork(any(), eq("SGP4")); @@ -133,29 +141,43 @@ void testRecord_badWork() { } @ParameterizedTest - @CsvSource({"STAN-1, true, true,", - "STAN-1, true, false, Labware STAN-1 is empty.", - "STAN-404, false, false, Unknown labware barcode: \"STAN-404\"", - ", false, false, No labware barcode supplied.", + @ValueSource(booleans={true,false}) + void testLoadLabware_missingBarcode(boolean bcNull) { + List barcodes = Arrays.asList("STAN-1", bcNull ? null : ""); + List problems = new ArrayList<>(1); + assertNull(service.loadLabware(problems, barcodes)); + assertProblem(problems, "Barcodes array has missing elements."); + } + + @ParameterizedTest + @CsvSource({"Stan-1, true,", + "STAN-1, false, Labware is empty: [STAN-1]", + "Stan-1/STAN-404, true, Unknown labware barcode: [\"STAN-404\"]", + ", false, No labware barcodes supplied.", }) - void testLoadLabware(String barcode, boolean exists, boolean containsSample, String expectedProblem) { - Labware lw; - if (!exists) { - lw = null; - } else if (containsSample) { - lw = EntityFactory.makeLabware(EntityFactory.getTubeType(), EntityFactory.getSample()); - } else { - lw = EntityFactory.makeEmptyLabware(EntityFactory.getTubeType()); - } - if (barcode!=null && !barcode.isEmpty()) { - if (lw!=null) { - lw.setBarcode(barcode); + void testLoadLabware(String barcodesJoined, boolean containsSample, String expectedProblem) { + List barcodes = (barcodesJoined == null ? null : Arrays.asList(barcodesJoined.split("/"))); + + String validBarcode = barcodes == null ? null + : barcodes.stream().filter(bc -> !bc.equalsIgnoreCase("STAN-404")).findAny().orElse(null); + List lwList; + if (validBarcode!=null) { + Labware lw; + if (containsSample) { + lw = EntityFactory.makeLabware(EntityFactory.getTubeType(), EntityFactory.getSample()); + } else { + lw = EntityFactory.makeEmptyLabware(EntityFactory.getTubeType()); } - when(mockLwRepo.findByBarcode(barcode)).thenReturn(Optional.ofNullable(lw)); + lw.setBarcode(validBarcode.toUpperCase()); + when(mockLwRepo.findByBarcodeIn(barcodes)).thenReturn(List.of(lw)); + lwList = List.of(lw); + } else { + when(mockLwRepo.findByBarcodeIn(any())).thenReturn(List.of()); + lwList = null; } final List problems = new ArrayList<>(expectedProblem==null ? 0 : 1); - assertSame(lw, service.loadLabware(problems, barcode)); + assertEquals(lwList, service.loadLabware(problems, barcodes)); assertProblem(problems, expectedProblem); } @@ -184,23 +206,38 @@ void testCheckDescription(String input, String expectedOutput, String expectedPr @ParameterizedTest @ValueSource(booleans={false,true}) void testCreate(boolean hasWork) { - Labware lw = EntityFactory.getTube(); + Labware lw1 = EntityFactory.getTube(); + Labware lw2 = EntityFactory.makeLabware(lw1.getLabwareType(), lw1.getFirstSlot().getSamples().getFirst()); + List labware = List.of(lw1, lw2); User user = EntityFactory.getUser(); Work work = (hasWork ? EntityFactory.makeWork("SGP1") : null); String desc = "Alpha beta"; Priority priority = Priority.flag; OperationType opType = EntityFactory.makeOperationType("Flag labware", null, OperationTypeFlag.IN_PLACE); - Operation op = new Operation(); - op.setId(500); - when(mockOpService.createOperationInPlace(any(), any(), any(), any(), any())).thenReturn(op); + final int[] opIdCounter = {500}; + final List returnedOps = new ArrayList<>(labware.size()); + when(mockOpService.createOperationInPlace(any(), any(), any(), any(), any())).then(invocation -> { + Operation op = new Operation(); + op.setId(++opIdCounter[0]); + returnedOps.add(op); + return op; + }); + final OperationResult result = service.create(user, opType, labware, desc, work, priority); + + for (Labware lw : labware) { + verify(mockOpService).createOperationInPlace(opType, user, lw, null, null); + } - assertEquals(new OperationResult(List.of(op), List.of(lw)), service.create(user, opType, lw, desc, work, priority)); + assertThat(returnedOps).hasSameSizeAs(labware); + assertEquals(new OperationResult(returnedOps, labware), result); - verify(mockOpService).createOperationInPlace(opType, user, lw, null, null); - verify(mockFlagRepo).save(new LabwareFlag(null, lw, desc, user, op.getId(), priority)); + List expectedFlags = Zip.map(labware.stream(), returnedOps.stream(), + (lw, op) -> new LabwareFlag(null, lw, desc, user, op.getId(), priority)) + .toList(); + verify(mockFlagRepo).saveAll(expectedFlags); if (hasWork) { - verify(mockWorkService).link(work, List.of(op)); + verify(mockWorkService).link(work, returnedOps); } else { verifyNoInteractions(mockWorkService); } diff --git a/src/test/resources/graphql/flaglabware.graphql b/src/test/resources/graphql/flaglabware.graphql index fac11753..7d014481 100644 --- a/src/test/resources/graphql/flaglabware.graphql +++ b/src/test/resources/graphql/flaglabware.graphql @@ -1,6 +1,6 @@ mutation { flagLabware(request: { - barcode: "[BC]" + barcodes: ["[BC]"] description: "[DESC]" workNumber: "[WORKNUM]" priority: flag From 0aa039f14c3d58154cd6c2eff44ce412680f1f41 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 6 Feb 2025 15:39:18 +0000 Subject: [PATCH 06/37] x1288 multiple work numbers (comma sep) in block register file --- .../filereader/BaseRegisterFileReader.java | 41 ++++++++++++++++--- .../BlockRegisterFileReaderImp.java | 29 +++++++++++-- .../TestBlockRegisterFileReader.java | 13 +++--- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/register/filereader/BaseRegisterFileReader.java b/src/main/java/uk/ac/sanger/sccp/stan/service/register/filereader/BaseRegisterFileReader.java index 8707ac68..e022ecfe 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/register/filereader/BaseRegisterFileReader.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/register/filereader/BaseRegisterFileReader.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.function.BiPredicate; import java.util.stream.Stream; import static java.util.stream.Collectors.toList; @@ -96,7 +97,7 @@ public Map indexColumns(Collection problems, Row ro List missingColumns = getColumns() .filter(c -> c.isRequired() && map.get(c)==null) .map(Object::toString) - .collect(toList()); + .toList(); if (!missingColumns.isEmpty()) { problems.add("Missing columns: "+missingColumns); } @@ -247,12 +248,42 @@ public T cellValue(Class type, Cell cell) { * @return the string found, or null if no string is found */ public String getUniqueString(Stream values, Runnable multipleValues) { - Iterable iter = values::iterator; - String found = null; - for (String value : iter) { + return getUnique(values, multipleValues, String::equalsIgnoreCase); + } + + /** + * Gets the unique value from the given stream. Null values are ignored. + * If multiple different values are found, the multipleValues function is called, + * and the first found value is returned. + * @param values the stream of values + * @param multipleValues callback to run if multiple different values are found + * @return the value found, or null if no value is found + * @param the type of values + */ + public V getUnique(Stream values, Runnable multipleValues) { + return getUnique(values, multipleValues, null); + } + + /** + * Gets the unique value from the given stream. Null values are ignored. + * If multiple different values are found, the multipleValues function is called, + * and the first found value is returned. + * @param values the stream of values + * @param multipleValues callback to run if multiple different values are found + * @param equal predicate to test if two values count as equal + * @return the value found, or null if no value is found + * @param the type of values + */ + public V getUnique(Stream values, Runnable multipleValues, BiPredicate equal) { + if (equal==null) { + equal = Object::equals; + } + Iterable iter = values::iterator; + V found = null; + for (V value : iter) { if (found==null) { found = value; - } else if (value != null && !found.equalsIgnoreCase(value)) { + } else if (value != null && !equal.test(found, value)) { multipleValues.run(); break; } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/register/filereader/BlockRegisterFileReaderImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/register/filereader/BlockRegisterFileReaderImp.java index 4449495a..63714f76 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/register/filereader/BlockRegisterFileReaderImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/register/filereader/BlockRegisterFileReaderImp.java @@ -14,6 +14,8 @@ import java.util.*; import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; +import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; /** * @author dr6 @@ -31,12 +33,33 @@ protected RegisterRequest createRequest(Collection problems, List blockRequests = rows.stream() .map(row -> createBlockRequest(problems, row)) .collect(toList()); - String workNumber = getUniqueString(rows.stream().map(row -> (String) row.get(Column.Work_number)), - () -> problems.add("Multiple work numbers specified.")); + Set workNumbers = getUnique(rows.stream().map(row -> workNumberSet((String) row.get(Column.Work_number))), + () -> problems.add("All rows must list the same work numbers.")); if (!problems.isEmpty()) { throw new ValidationException("The file contents are invalid.", problems); } - return new RegisterRequest(blockRequests, workNumber==null ? List.of() : List.of(workNumber)); + return new RegisterRequest(blockRequests, nullOrEmpty(workNumbers) ? List.of() : new ArrayList<>(workNumbers)); + } + + /** + * Gets the set of work numbers specified in a row. + * Null if none are specified. + * @param string the string listing zero, one or more work numbers + * @return a nonempty set of work numbers, or null + */ + public static Set workNumberSet(String string) { + if (string == null) { + return null; + } + string = string.trim().toUpperCase(); + if (string.isEmpty()) { + return null; + } + String[] wns = string.replace(',',' ').split("\\s+"); + Set set = Arrays.stream(wns) + .filter(s -> !s.isEmpty()) + .collect(toSet()); + return (set.isEmpty() ? null : set); } /** diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/register/filereader/TestBlockRegisterFileReader.java b/src/test/java/uk/ac/sanger/sccp/stan/service/register/filereader/TestBlockRegisterFileReader.java index 41d8887f..e72d779d 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/register/filereader/TestBlockRegisterFileReader.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/register/filereader/TestBlockRegisterFileReader.java @@ -13,6 +13,7 @@ import uk.ac.sanger.sccp.stan.request.register.BlockRegisterRequest; import uk.ac.sanger.sccp.stan.request.register.RegisterRequest; import uk.ac.sanger.sccp.stan.service.register.filereader.BlockRegisterFileReader.Column; +import uk.ac.sanger.sccp.utils.Zip; import java.io.IOException; import java.time.LocalDate; @@ -302,18 +303,18 @@ static Stream cellValueMocks() { @Test void testCreateRequest() { List> rows = List.of( - rowMap("SGP1", "X1"), + rowMap("SGP1, SGP2 sgp3,sgp2", "X1"), + rowMap("sgp1 sgp3 sgp2", "X2"), rowMap(null, null) ); - List brs = IntStream.range(1, 3) + List brs = IntStream.rangeClosed(1, rows.size()) .mapToObj(i -> makeBlockRegisterRequest("X"+i)) .collect(toList()); - doReturn(brs.get(0)).when(reader).createBlockRequest(any(), same(rows.get(0))); - doReturn(brs.get(1)).when(reader).createBlockRequest(any(), same(rows.get(1))); + Zip.forEach(rows.stream(), brs.stream(), (row, br) -> doReturn(br).when(reader).createBlockRequest(any(), same(row))); final List problems = new ArrayList<>(); RegisterRequest request = reader.createRequest(problems, rows); - assertThat(request.getWorkNumbers()).containsExactly("SGP1"); + assertThat(request.getWorkNumbers()).containsExactlyInAnyOrder("SGP1", "SGP2", "SGP3"); assertEquals(brs, request.getBlocks()); } @@ -331,7 +332,7 @@ void testCreateRequest_problems() { Matchers.mayAddProblem("Bad stuff.", srls.get(1)).when(reader).createBlockRequest(any(), same(rows.get(1))); assertValidationError(() -> reader.createRequest(new ArrayList<>(), rows), - "Multiple work numbers specified.", + "All rows must list the same work numbers.", "Bad stuff."); } From 337309224954c0abb5ab0c0f40a8965c7fe91ee4 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 7 Feb 2025 12:35:30 +0000 Subject: [PATCH 07/37] v3.4.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 2d489e75..1f7151c3 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.3.1 + 3.4.0 stan Spatial Genomics LIMS From e3c92696afcf521ec8d3b175d94ecfaa2fe7944c Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:17:54 +0000 Subject: [PATCH 08/37] x1298: double omero project name length --- .../java/uk/ac/sanger/sccp/stan/config/FieldValidation.java | 2 +- src/main/resources/db/changelog/changelog-2.19.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/config/FieldValidation.java b/src/main/java/uk/ac/sanger/sccp/stan/config/FieldValidation.java index 99b81f11..5a314148 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/config/FieldValidation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/config/FieldValidation.java @@ -330,7 +330,7 @@ public Validator omeroProjectNameValidator() { Set charTypes = EnumSet.of( CharacterType.ALPHA, CharacterType.DIGIT, CharacterType.UNDERSCORE ); - return new StringValidator("Omero project name", 1, 16, charTypes); + return new StringValidator("Omero project name", 1, 32, charTypes); } @Bean diff --git a/src/main/resources/db/changelog/changelog-2.19.xml b/src/main/resources/db/changelog/changelog-2.19.xml index b56a6afe..6df30ee0 100644 --- a/src/main/resources/db/changelog/changelog-2.19.xml +++ b/src/main/resources/db/changelog/changelog-2.19.xml @@ -8,7 +8,7 @@ - + From f8df8d60a35f5fae14c2354dc5f41ebe6590fb9e Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:33:35 +0000 Subject: [PATCH 09/37] v3.4.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1f7151c3..8c5f2fde 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.4.0 + 3.4.1 stan Spatial Genomics LIMS From 1e952018ac38e6910768993ca84dd58466ccc21d Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:41:13 +0000 Subject: [PATCH 10/37] v3.3.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 2ebc1513..ab07874c 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.3.2 + 3.3.3 stan Spatial Genomics LIMS From 4f5be4084f813b20781682bae84350c8a654a7d0 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Mon, 24 Mar 2025 10:05:01 +0000 Subject: [PATCH 11/37] v3.5.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8c5f2fde..504c3221 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.4.1 + 3.5.0 stan Spatial Genomics LIMS From b83d77959aac96da3a52d12a1579773d26bc8559 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 27 Mar 2025 16:09:18 +0000 Subject: [PATCH 12/37] x1303 Add Warranty_replacement to SlideCosting enum --- .../uk/ac/sanger/sccp/stan/model/SlideCosting.java | 2 +- .../{changelog-3.30.xml => changelog-3.03.xml} | 0 src/main/resources/db/changelog/changelog-3.05.xml | 13 +++++++++++++ .../resources/db/changelog/changelog-master.xml | 3 ++- src/main/resources/schema.graphqls | 2 +- .../integrationtest/TestProbeOperationMutation.java | 2 +- .../resources/graphql/recordprobeoperation.graphql | 2 +- 7 files changed, 19 insertions(+), 5 deletions(-) rename src/main/resources/db/changelog/{changelog-3.30.xml => changelog-3.03.xml} (100%) create mode 100644 src/main/resources/db/changelog/changelog-3.05.xml diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/SlideCosting.java b/src/main/java/uk/ac/sanger/sccp/stan/model/SlideCosting.java index 9f422dbb..0c920611 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/model/SlideCosting.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/SlideCosting.java @@ -4,5 +4,5 @@ * Some information about the costing of a slide. */ public enum SlideCosting { - Faculty, SGP + Faculty, SGP, Warranty_replacement } diff --git a/src/main/resources/db/changelog/changelog-3.30.xml b/src/main/resources/db/changelog/changelog-3.03.xml similarity index 100% rename from src/main/resources/db/changelog/changelog-3.30.xml rename to src/main/resources/db/changelog/changelog-3.03.xml diff --git a/src/main/resources/db/changelog/changelog-3.05.xml b/src/main/resources/db/changelog/changelog-3.05.xml new file mode 100644 index 00000000..9816816c --- /dev/null +++ b/src/main/resources/db/changelog/changelog-3.05.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/changelog-master.xml b/src/main/resources/db/changelog/changelog-master.xml index fdfaa075..a9d034bb 100644 --- a/src/main/resources/db/changelog/changelog-master.xml +++ b/src/main/resources/db/changelog/changelog-master.xml @@ -33,5 +33,6 @@ - + + diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index 43a67821..4d973268 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -54,7 +54,7 @@ enum LifeStage { """Some information about the costing of a slide.""" enum SlideCosting { - Faculty, SGP + Faculty, SGP, Warranty_replacement } """Position of a cassette in an analyser.""" diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestProbeOperationMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestProbeOperationMutation.java index f1d25aa7..68f56c9e 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestProbeOperationMutation.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestProbeOperationMutation.java @@ -95,7 +95,7 @@ public void testRecordProbeOperation() throws Exception { assertEquals("probe2", lwp.getProbePanel().getName()); assertEquals(2, lwp.getPlex()); assertEquals("LOT2", lwp.getLotNumber()); - assertEquals(SlideCosting.SGP, lwp.getCosting()); + assertEquals(SlideCosting.Warranty_replacement, lwp.getCosting()); List notes = lwNoteRepo.findAllByOperationIdIn(List.of(opId)); assertThat(notes).hasSize(2); notes.forEach(note -> assertEquals(lw.getId(), note.getLabwareId())); diff --git a/src/test/resources/graphql/recordprobeoperation.graphql b/src/test/resources/graphql/recordprobeoperation.graphql index a66cadcf..5535c7e1 100644 --- a/src/test/resources/graphql/recordprobeoperation.graphql +++ b/src/test/resources/graphql/recordprobeoperation.graphql @@ -13,7 +13,7 @@ mutation { name: "probe2" lot: "lot2" plex: 2 - costing: SGP + costing: Warranty_replacement }] workNumber: "SGP1" samplePrepReagentLot: "123456" From 3e4a9cd90b72c7d3c6ee73f4aeac83e6d6f4e3f3 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 10 Apr 2025 12:37:02 +0100 Subject: [PATCH 13/37] x1307: API to add a tissue type (with spatial locations) --- .../ac/sanger/sccp/stan/GraphQLMutation.java | 14 +- .../ac/sanger/sccp/stan/GraphQLProvider.java | 1 + .../sccp/stan/config/FieldValidation.java | 20 ++ .../sccp/stan/model/SpatialLocation.java | 10 +- .../sanger/sccp/stan/repo/TissueTypeRepo.java | 1 + .../stan/request/AddTissueTypeRequest.java | 136 +++++++++ .../sccp/stan/service/TissueTypeService.java | 14 + .../stan/service/TissueTypeServiceImp.java | 148 ++++++++++ src/main/resources/schema.graphqls | 20 ++ .../TestAddTissueTypeMutation.java | 65 +++++ .../stan/service/TestTissueTypeService.java | 258 ++++++++++++++++++ .../resources/graphql/addtissuetype.graphql | 19 ++ 12 files changed, 704 insertions(+), 2 deletions(-) create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/request/AddTissueTypeRequest.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeService.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeServiceImp.java create mode 100644 src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestAddTissueTypeMutation.java create mode 100644 src/test/java/uk/ac/sanger/sccp/stan/service/TestTissueTypeService.java create mode 100644 src/test/resources/graphql/addtissuetype.graphql diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java index 65e1cdc0..72425bb8 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java @@ -104,6 +104,7 @@ public class GraphQLMutation extends BaseGraphQLResource { final RoiMetricService roiMetricService; final UserAdminService userAdminService; final SlotCopyRecordService slotCopyRecordService; + final TissueTypeService tissueTypeService; @Autowired public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authComp, @@ -137,7 +138,8 @@ public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authCo QCLabwareService qcLabwareService, OrientationService orientationService, SSStudyService ssStudyService, ReactivateService reactivateService, LibraryPrepService libraryPrepService, SegmentationService segmentationService, CleanOutService cleanOutService, RoiMetricService roiMetricService, - UserAdminService userAdminService, SlotCopyRecordService slotCopyRecordService) { + UserAdminService userAdminService, SlotCopyRecordService slotCopyRecordService, + TissueTypeService tissueTypeService) { super(objectMapper, authComp, userRepo); this.authService = authService; this.registerService = registerService; @@ -202,6 +204,7 @@ public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authCo this.roiMetricService = roiMetricService; this.userAdminService = userAdminService; this.slotCopyRecordService = slotCopyRecordService; + this.tissueTypeService = tissueTypeService; } private void logRequest(String name, User user, Object request) { @@ -930,6 +933,15 @@ public DataFetcher saveSlotCopy() { }; } + public DataFetcher addTissueType() { + return dfe -> { + User user = checkUser(dfe, User.Role.normal); + AddTissueTypeRequest request = arg(dfe, "request", AddTissueTypeRequest.class); + logRequest("addTissueType", user, request); + return tissueTypeService.perform(request); + }; + } + public DataFetcher addUser() { return adminAdd(userAdminService::addNormalUser, "AddUser", "username"); } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java index 80dbfdbb..6a702aa4 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java @@ -205,6 +205,7 @@ private RuntimeWiring buildWiring() { .dataFetcher("updateWorkOmeroProject", transact(graphQLMutation.updateWorkOmeroProject())) .dataFetcher("updateWorkDnapStudy", transact(graphQLMutation.updateWorkDnapStudy())) .dataFetcher("updateDnapStudies", graphQLMutation.updateDnapStudies()) // transacted internally + .dataFetcher("addTissueType", transact(graphQLMutation.addTissueType())) .dataFetcher("stain", transact(graphQLMutation.stain())) .dataFetcher("unrelease", transact(graphQLMutation.unrelease())) .dataFetcher("recordStainResult", transact(graphQLMutation.recordStainResult())) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/config/FieldValidation.java b/src/main/java/uk/ac/sanger/sccp/stan/config/FieldValidation.java index 5a314148..b2554440 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/config/FieldValidation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/config/FieldValidation.java @@ -371,6 +371,26 @@ public Validator bioRiskCodeValidator() { return new StringValidator("Bio risk code", 2, 20, charTypes); } + @Bean + public Validator tissueTypeNameValidator() { + Set charTypes = EnumSet.of(CharacterType.ALPHA, CharacterType.DIGIT, CharacterType.PAREN, + CharacterType.SPACE, CharacterType.COMMA, CharacterType.FULL_STOP, CharacterType.SLASH); + return new StringValidator("Tissue type name", 2, 64, charTypes); + } + + @Bean + public Validator tissueTypeCodeValidator() { + Set charTypes = EnumSet.of(CharacterType.ALPHA); + return new StringValidator("Tissue type code", 2, 4, charTypes); + } + + @Bean + public Validator spatialLocationNameValidator() { + Set charTypes = EnumSet.of(CharacterType.ALPHA, CharacterType.DIGIT, CharacterType.PAREN, + CharacterType.SPACE, CharacterType.COMMA, CharacterType.FULL_STOP, CharacterType.SLASH); + return new StringValidator("Spatial location name", 1, 64, charTypes); + } + @Bean public Clock clock() { return Clock.systemUTC(); diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/SpatialLocation.java b/src/main/java/uk/ac/sanger/sccp/stan/model/SpatialLocation.java index 4094dabf..d596a86f 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/model/SpatialLocation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/SpatialLocation.java @@ -65,10 +65,18 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SpatialLocation that = (SpatialLocation) o; + if (this.getTissueType() != that.getTissueType()) { + if (this.getTissueType()==null || that.getTissueType()==null) { + return false; + } + if (!Objects.equals(this.getTissueType().getId(), that.getTissueType().getId())) { + return false; + } + } return (Objects.equals(this.id, that.id) && Objects.equals(this.name, that.name) && Objects.equals(this.code, that.code) - && Objects.equals(this.tissueType, that.tissueType)); + ); } @Override diff --git a/src/main/java/uk/ac/sanger/sccp/stan/repo/TissueTypeRepo.java b/src/main/java/uk/ac/sanger/sccp/stan/repo/TissueTypeRepo.java index 18305209..0f90317a 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/repo/TissueTypeRepo.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/repo/TissueTypeRepo.java @@ -7,6 +7,7 @@ public interface TissueTypeRepo extends CrudRepository { Optional findByName(String name); + Optional findByCode(String code); List findAllByNameIn(Collection names); } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/AddTissueTypeRequest.java b/src/main/java/uk/ac/sanger/sccp/stan/request/AddTissueTypeRequest.java new file mode 100644 index 00000000..f40c636b --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/AddTissueTypeRequest.java @@ -0,0 +1,136 @@ +package uk.ac.sanger.sccp.stan.request; + +import java.util.List; +import java.util.Objects; + +import static uk.ac.sanger.sccp.utils.BasicUtils.*; + +/** + * A request to add a tissue type. + * @author dr6 + */ +public class AddTissueTypeRequest { + private String name; + private String code; + private List spatialLocations = List.of(); + + public AddTissueTypeRequest(String name, String code, List spatialLocations) { + setName(name); + setCode(code); + setSpatialLocations(spatialLocations); + } + + public AddTissueTypeRequest(String name, String code) { + this(name, code, null); + } + + public AddTissueTypeRequest() { + this(null, null, null); + } + + /** The name of the tissue type. */ + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + /** The short code for the tissue type. */ + public String getCode() { + return this.code; + } + + public void setCode(String code) { + this.code = code; + } + + /** The spatial locations for the new tissue type. */ + public List getSpatialLocations() { + return this.spatialLocations; + } + + public void setSpatialLocations(List spatialLocations) { + this.spatialLocations = nullToEmpty(spatialLocations); + } + + @Override + public String toString() { + return describe(this) + .add("name", name) + .add("code", code) + .add("spatialLocations", spatialLocations) + .reprStringValues() + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || o.getClass() != this.getClass()) return false; + AddTissueTypeRequest that = (AddTissueTypeRequest) o; + return (Objects.equals(this.name, that.name) + && Objects.equals(this.code, that.code) + && Objects.equals(this.spatialLocations, that.spatialLocations) + ); + } + + @Override + public int hashCode() { + return (name==null ? 0 : name.hashCode()); + } + + /** A spatial location for the new tissue type */ + public static class NewSpatialLocation { + private int code; + private String name; + + public NewSpatialLocation(int code, String name) { + this.code = code; + this.name = name; + } + + public NewSpatialLocation() { + this(0, null); + } + + /** The int code for the spatial location. */ + public int getCode() { + return this.code; + } + + public void setCode(int code) { + this.code = code; + } + + /** The name of the spatial location. */ + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return String.format("[%s: %s]", code, repr(name)); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || o.getClass() != this.getClass()) return false; + NewSpatialLocation that = (NewSpatialLocation) o; + return (this.code==that.code + && Objects.equals(this.name, that.name) + ); + } + + @Override + public int hashCode() { + return (name==null ? 0 : name.hashCode()); + } + } +} \ No newline at end of file diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeService.java new file mode 100644 index 00000000..4b113666 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeService.java @@ -0,0 +1,14 @@ +package uk.ac.sanger.sccp.stan.service; + +import uk.ac.sanger.sccp.stan.model.TissueType; +import uk.ac.sanger.sccp.stan.request.AddTissueTypeRequest; + +/** Service managing tissue types and spatial locations */ +public interface TissueTypeService { + /** + * Adds a tissue type based on the given request. + * @param request specification of new tissue type + * @return the new tissue type + */ + TissueType perform(AddTissueTypeRequest request); +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeServiceImp.java new file mode 100644 index 00000000..313295de --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeServiceImp.java @@ -0,0 +1,148 @@ +package uk.ac.sanger.sccp.stan.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import uk.ac.sanger.sccp.stan.model.SpatialLocation; +import uk.ac.sanger.sccp.stan.model.TissueType; +import uk.ac.sanger.sccp.stan.repo.SpatialLocationRepo; +import uk.ac.sanger.sccp.stan.repo.TissueTypeRepo; +import uk.ac.sanger.sccp.stan.request.AddTissueTypeRequest; +import uk.ac.sanger.sccp.stan.request.AddTissueTypeRequest.NewSpatialLocation; + +import java.util.*; +import java.util.function.Consumer; + +import static uk.ac.sanger.sccp.utils.BasicUtils.asList; +import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; + +/** + * @author dr6 + */ +@Service +public class TissueTypeServiceImp implements TissueTypeService { + private final TissueTypeRepo ttRepo; + private final SpatialLocationRepo slRepo; + private final Validator ttNameValidator, ttCodeValidator, slNameValidator; + + @Autowired + public TissueTypeServiceImp(TissueTypeRepo ttRepo, SpatialLocationRepo slRepo, + @Qualifier("tissueTypeNameValidator") Validator ttNameValidator, + @Qualifier("tissueTypeCodeValidator") Validator ttCodeValidator, + @Qualifier("spatialLocationNameValidator") Validator slNameValidator) { + this.ttRepo = ttRepo; + this.slRepo = slRepo; + this.ttNameValidator = ttNameValidator; + this.ttCodeValidator = ttCodeValidator; + this.slNameValidator = slNameValidator; + } + + @Override + public TissueType perform(AddTissueTypeRequest request) { + sanitise(request); + Set problems = validate(request); + if (!problems.isEmpty()) { + throw new ValidationException(problems); + } + return execute(request); + } + + /** + * Sanitises the contents of the request. + * Trims the strings, and capitalises the tissue type code. + * Skips null values. + * @param request the request to sanitise + */ + public void sanitise(AddTissueTypeRequest request) { + if (request==null) { + return; + } + if (request.getName()!=null) { + request.setName(request.getName().trim()); + } + if (request.getCode()!=null) { + request.setCode(request.getCode().toUpperCase().trim()); + } + if (request.getSpatialLocations()!=null) { + for (var sl : request.getSpatialLocations()) { + if (sl.getName()!=null) { + sl.setName(sl.getName().trim()); + } + } + } + } + + /** + * Checks for problems with the request. Returns any problems found. + * @param request the request to validate + * @return descriptions of problems found + */ + public Set validate(AddTissueTypeRequest request) { + Set problems = new LinkedHashSet<>(); + if (request==null) { + problems.add("No request supplied."); + return problems; + } + Consumer addProblem = problems::add; + ttNameValidator.validate(request.getName(), addProblem); + ttCodeValidator.validate(request.getCode(), addProblem); + validateSpatialLocations(problems, request.getSpatialLocations()); + checkExistingTissueTypes(problems, request.getName(), request.getCode()); + return problems; + } + + /** + * Checks the spatial locations for problems. + * @param problems receptacle for problems found + * @param sls the information about spatial locations to create + */ + public void validateSpatialLocations(Collection problems, List sls) { + if (nullOrEmpty(sls)) { + problems.add("No spatial locations specified."); + return; + } + Set seenCodes = new HashSet<>(sls.size()); + final Consumer addProblem = problems::add; + for (NewSpatialLocation sl : sls) { + if (sl.getCode() < 0) { + problems.add("Spatial location codes cannot be negative numbers."); + } else if (!seenCodes.add(sl.getCode())) { + problems.add("Spatial locations cannot contain duplicate codes."); + } + slNameValidator.validate(sl.getName(), addProblem); + } + } + + /** + * Checks if a tissue type already exists with the given name or code + * @param problems receptacle for problems + * @param name the name to look for + * @param code the code to look for + */ + public void checkExistingTissueTypes(final Collection problems, String name, String code) { + if (!nullOrEmpty(name)) { + var optTt = ttRepo.findByName(name); + if (optTt.isPresent()) { + problems.add("Tissue type already exists: "+optTt.get().getName()); + return; + } + } + if (!nullOrEmpty(code)) { + ttRepo.findByCode(code).ifPresent(tt -> problems.add("Tissue type code already in use: "+tt.getCode())); + } + } + + /** + * Creates the new tissue type and spatial locations + * @param request specification of the new tissue type + * @return the new tissue type + */ + public TissueType execute(final AddTissueTypeRequest request) { + TissueType tissueType = ttRepo.save(new TissueType(null, request.getName(), request.getCode())); + List sls = request.getSpatialLocations().stream() + .map(nsl -> new SpatialLocation(null, nsl.getName(), nsl.getCode(), tissueType)) + .toList(); + tissueType.setSpatialLocations(asList(slRepo.saveAll(sls))); + return tissueType; + } +} diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index 4d973268..fa4487e5 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -2091,6 +2091,24 @@ input SampleMetricsRequest { metrics: [SampleMetric!]! } +"""A request to add a tissue type.""" +input AddTissueTypeRequest { + """The name of the tissue type.""" + name: String! + """The short code for the tissue type.""" + code: String! + """The spatial locations for the new tissue type.""" + spatialLocations: [AddTissueTypeSpatialLocation!]! +} + +"""A spatial location for a new tissue type.""" +input AddTissueTypeSpatialLocation { + """The int code for the spatial location.""" + code: Int! + """The name of the spatial location.""" + name: String! +} + """Info about the app version.""" type VersionInfo { """The output of git describe.""" @@ -2378,6 +2396,8 @@ type Mutation { updateWorkDnapStudy(workNumber: String!, ssStudyId: Int): Work! """Updates Stan's internal list of Dnap Studies, and returns the enabled ones.""" updateDnapStudies: [DnapStudy!]! + """Add a new tissue type.""" + addTissueType(request: AddTissueTypeRequest!): TissueType! """Record a new stain with time measurements.""" stain(request: StainRequest!): OperationResult! diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestAddTissueTypeMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestAddTissueTypeMutation.java new file mode 100644 index 00000000..79128403 --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestAddTissueTypeMutation.java @@ -0,0 +1,65 @@ +package uk.ac.sanger.sccp.stan.integrationtest; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import uk.ac.sanger.sccp.stan.EntityCreator; +import uk.ac.sanger.sccp.stan.GraphQLTester; +import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.repo.TissueTypeRepo; + +import javax.transaction.Transactional; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static uk.ac.sanger.sccp.stan.integrationtest.IntegrationTestUtils.chainGet; + +/** + * Tests the add tissue type mutation. + * @author dr6 + */ +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +@Import({GraphQLTester.class, EntityCreator.class}) +public class TestAddTissueTypeMutation { + @Autowired + private GraphQLTester tester; + @Autowired + private EntityCreator entityCreator; + @Autowired + private TissueTypeRepo ttRepo; + + @Test + @Transactional + public void testCleanOut() throws Exception { + User user = entityCreator.createUser("user1"); + tester.setUser(user); + String mutation = tester.readGraphQL("addtissuetype.graphql"); + Object response = tester.post(mutation); + Map ttData = chainGet(response, "data", "addTissueType"); + assertEquals("Bananas", ttData.get("name")); + List> slsData = chainGet(ttData, "spatialLocations"); + assertThat(slsData).hasSize(2); + assertThat(slsData).containsExactly(Map.of("code", 0, "name", "No spatial information"), + Map.of("code", 1, "name", "SL1")); + TissueType tt = ttRepo.findByName("Bananas").orElseThrow(); + assertNotNull(tt.getId()); + assertEquals("Bananas", tt.getName()); + assertEquals("BAN", tt.getCode()); + List sls = tt.getSpatialLocations(); + assertThat(sls).hasSize(2); + for (int i = 0; i < sls.size(); i++) { + SpatialLocation sl = sls.get(i); + assertNotNull(sl.getId()); + assertEquals(i, sl.getCode()); + assertEquals(i==0 ? "No spatial information" : "SL1", sl.getName()); + } + } +} diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/TestTissueTypeService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/TestTissueTypeService.java new file mode 100644 index 00000000..d5aafd45 --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/TestTissueTypeService.java @@ -0,0 +1,258 @@ +package uk.ac.sanger.sccp.stan.service; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.ac.sanger.sccp.stan.EntityFactory; +import uk.ac.sanger.sccp.stan.model.SpatialLocation; +import uk.ac.sanger.sccp.stan.model.TissueType; +import uk.ac.sanger.sccp.stan.repo.SpatialLocationRepo; +import uk.ac.sanger.sccp.stan.repo.TissueTypeRepo; +import uk.ac.sanger.sccp.stan.request.AddTissueTypeRequest; +import uk.ac.sanger.sccp.stan.request.AddTissueTypeRequest.NewSpatialLocation; + +import java.util.*; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static uk.ac.sanger.sccp.stan.Matchers.*; + +class TestTissueTypeService { + @Mock + TissueTypeRepo mockTtRepo; + @Mock + SpatialLocationRepo mockSlRepo; + @Mock + Validator mockTtNameValidator; + @Mock + Validator mockTtCodeValidator; + @Mock + Validator mockSlNameValidator; + + private TissueTypeServiceImp service; + + private AutoCloseable mocking; + + @BeforeEach + void setup() { + mocking = MockitoAnnotations.openMocks(this); + service = spy(new TissueTypeServiceImp(mockTtRepo, mockSlRepo, + mockTtNameValidator, mockTtCodeValidator, mockSlNameValidator)); + } + + @AfterEach + void cleanup() throws Exception { + mocking.close(); + } + + @ParameterizedTest + @ValueSource(booleans={true, false}) + void testPerform(boolean valid) { + AddTissueTypeRequest request = new AddTissueTypeRequest("Bananas", "BNS"); + doNothing().when(service).sanitise(any()); + Set problems = valid ? Set.of() : Set.of("Bad thing."); + doReturn(problems).when(service).validate(any()); + if (valid) { + TissueType newTissueType = EntityFactory.getTissueType(); + doReturn(newTissueType).when(service).execute(any()); + assertSame(newTissueType, service.perform(request)); + } else { + assertValidationException(() -> service.perform(request), problems); + } + verify(service).sanitise(request); + verify(service).validate(request); + verify(service, times(valid ? 1 : 0)).execute(request); + } + + @Test + void testSanitise_null() { + service.sanitise(null); // does nothing + } + + @ParameterizedTest + @CsvSource({ + ",,,", + "Alpha,,Alpha,", + ",ALP,,ALP", + "' Alpha ',' alp',Alpha,ALP" + }) + void testSanitise_tt(String iName, String iCode, String eName, String eCode) { + AddTissueTypeRequest request = new AddTissueTypeRequest(iName, iCode); + service.sanitise(request); + assertEquals(eName, request.getName()); + assertEquals(eCode, request.getCode()); + assertThat(request.getSpatialLocations()).isEmpty(); + } + + @Test + void testSanitise_sls() { + List sls = List.of( + new NewSpatialLocation(0, null), + new NewSpatialLocation(1, "Alpha"), + new NewSpatialLocation(2, " Beta ") + ); + AddTissueTypeRequest request = new AddTissueTypeRequest(null, null, sls); + service.sanitise(request); + assertNull(request.getSpatialLocations().get(0).getName()); + assertEquals("Alpha", request.getSpatialLocations().get(1).getName()); + assertEquals("Beta", request.getSpatialLocations().get(2).getName()); + } + + @Test + void testValidate_null() { + Set problems = service.validate(null); + assertProblem(problems, "No request supplied."); + } + + @Test + void testValidate_problems() { + when(mockTtNameValidator.validate(any(), any())).then(invocation -> { + String name = invocation.getArgument(0); + Consumer consumer = invocation.getArgument(1); + consumer.accept("Bad tt name: " + name); + return false; + }); + when(mockTtCodeValidator.validate(any(), any())).then(invocation -> { + String code = invocation.getArgument(0); + Consumer consumer = invocation.getArgument(1); + consumer.accept("Bad tt code: " + code); + return false; + }); + mayAddProblem("Bad SL").when(service).validateSpatialLocations(any(), any()); + mayAddProblem("Existing tt").when(service).checkExistingTissueTypes(any(), any(), any()); + + AddTissueTypeRequest request = new AddTissueTypeRequest("Alpha", "ALP", List.of(new NewSpatialLocation(0, "sl0"))); + + Collection problems = service.validate(request); + verify(mockTtNameValidator).validate(eq("Alpha"), any()); + verify(mockTtCodeValidator).validate(eq("ALP"), any()); + verify(service).validateSpatialLocations(any(), same(request.getSpatialLocations())); + verify(service).checkExistingTissueTypes(any(), eq("Alpha"), eq("ALP")); + + assertThat(problems).containsExactlyInAnyOrder("Bad tt name: Alpha", "Bad tt code: ALP", "Bad SL", "Existing tt"); + } + + @Test + void testValidate_noProblems() { + when(mockTtNameValidator.validate(any(), any())).thenReturn(true); + when(mockTtCodeValidator.validate(any(), any())).thenReturn(true); + doNothing().when(service).validateSpatialLocations(any(), any()); + doNothing().when(service).checkExistingTissueTypes(any(), any(), any()); + AddTissueTypeRequest request = new AddTissueTypeRequest("Alpha", "ALP", List.of(new NewSpatialLocation(0, "sl0"))); + + Collection problems = service.validate(request); + verify(mockTtNameValidator).validate(eq("Alpha"), any()); + verify(mockTtCodeValidator).validate(eq("ALP"), any()); + verify(service).validateSpatialLocations(any(), same(request.getSpatialLocations())); + verify(service).checkExistingTissueTypes(any(), eq("Alpha"), eq("ALP")); + assertThat(problems).isEmpty(); + } + + @Test + public void testValidateSpatialLocations_null() { + List problems = new ArrayList<>(1); + service.validateSpatialLocations(problems, null); + assertProblem(problems, "No spatial locations specified."); + verifyNoInteractions(mockSlNameValidator); + } + + @Test + public void testValidateSpatialLocations_valid() { + List sls = List.of( + new NewSpatialLocation(0, "sl0"), + new NewSpatialLocation(1, "sl1") + ); + List problems = new ArrayList<>(0); + service.validateSpatialLocations(problems, sls); + verify(mockSlNameValidator).validate(eq("sl0"), any()); + verify(mockSlNameValidator).validate(eq("sl1"), any()); + assertThat(problems).isEmpty(); + } + + @Test + public void testValidateSpatialLocations_invalid() { + List sls = List.of( + new NewSpatialLocation(0, "sl0"), + new NewSpatialLocation(-5, "sl-5"), + new NewSpatialLocation(1, "sl1"), + new NewSpatialLocation(1, "sl2"), + new NewSpatialLocation(3, "sl3!") + ); + when(mockSlNameValidator.validate(any(), any())).then(invocation -> { + String name = invocation.getArgument(0); + Consumer consumer = invocation.getArgument(1); + if (name.indexOf('!') < 0) { + return true; + } + consumer.accept("Bad sl name: " + name); + return false; + }); + List problems = new ArrayList<>(3); + service.validateSpatialLocations(problems, sls); + for (NewSpatialLocation sl : sls) { + verify(mockSlNameValidator).validate(eq(sl.getName()), any()); + } + assertThat(problems).containsExactlyInAnyOrder("Spatial location codes cannot be negative numbers.", + "Spatial locations cannot contain duplicate codes.", "Bad sl name: sl3!"); + } + + @Test + public void testCheckExistingTissueTypes_null() { + List problems = new ArrayList<>(0); + service.checkExistingTissueTypes(problems, null, null); + verifyNoInteractions(mockTtRepo); + assertThat(problems).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"name", "code", "none"}) + public void testCheckExistingTissueTypes(String match) { + boolean findMatch = !match.equals("none"); + List problems = new ArrayList<>(findMatch ? 1 : 0); + Optional found = findMatch ? Optional.of(new TissueType(10, "Alpha", "ALP")) : Optional.empty(); + when(mockTtRepo.findByName("alpha")).thenReturn(match.equals("name") ? found : Optional.empty()); + when(mockTtRepo.findByCode("alp")).thenReturn(match.equals("code") ? found : Optional.empty()); + + service.checkExistingTissueTypes(problems, "alpha", "alp"); + String expectedProblem = switch(match) { + case "name" -> "Tissue type already exists: Alpha"; + case "code" -> "Tissue type code already in use: ALP"; + default -> null; + }; + assertProblem(problems, expectedProblem); + } + + @Test + public void testExecute() { + AddTissueTypeRequest request = new AddTissueTypeRequest("Alpha", "ALP", + List.of(new NewSpatialLocation(0, "sl0"), + new NewSpatialLocation(1, "sl1"))); + when(mockTtRepo.save(any())).then(invocation -> { + TissueType tt = invocation.getArgument(0); + tt.setId(10); + return tt; + }); + when(mockSlRepo.saveAll(any())).then(invocation -> { + Iterable sls = invocation.getArgument(0); + for (SpatialLocation sl : sls) { + sl.setId(100 + sl.getCode()); + } + return sls; + }); + TissueType expected = new TissueType(10, "Alpha", "ALP"); + expected.setSpatialLocations(List.of(new SpatialLocation(100, "sl0", 0, expected), + new SpatialLocation(101, "sl1", 1, expected))); + + TissueType result = service.execute(request); + assertEquals(expected, result); + assertEquals(expected.getSpatialLocations(), result.getSpatialLocations()); + verify(mockTtRepo).save(result); + verify(mockSlRepo).saveAll(result.getSpatialLocations()); + } +} \ No newline at end of file diff --git a/src/test/resources/graphql/addtissuetype.graphql b/src/test/resources/graphql/addtissuetype.graphql new file mode 100644 index 00000000..77e40428 --- /dev/null +++ b/src/test/resources/graphql/addtissuetype.graphql @@ -0,0 +1,19 @@ +mutation { + addTissueType(request: { + name: "Bananas " + code: " ban" + spatialLocations: [{ + code: 0 + name: "No spatial information" + }, { + code: 1 + name: " SL1 " + }] + }) { + name + spatialLocations { + code + name + } + } +} \ No newline at end of file From b885237ab8b0aabf079a55cb6c06713bef3b6605 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 10 Apr 2025 15:57:34 +0100 Subject: [PATCH 14/37] x1307: API to add spatial locations to an existing tissue type --- .../ac/sanger/sccp/stan/GraphQLMutation.java | 11 +- .../ac/sanger/sccp/stan/GraphQLProvider.java | 1 + .../sccp/stan/service/TissueTypeService.java | 9 +- .../stan/service/TissueTypeServiceImp.java | 106 +++++++++- src/main/resources/schema.graphqls | 10 + .../TestAddTissueTypeMutation.java | 30 ++- .../stan/service/TestTissueTypeService.java | 195 ++++++++++++++++-- .../graphql/addspatiallocations.graphql | 18 ++ 8 files changed, 355 insertions(+), 25 deletions(-) create mode 100644 src/test/resources/graphql/addspatiallocations.graphql diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java index 72425bb8..3319875d 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java @@ -938,7 +938,16 @@ public DataFetcher addTissueType() { User user = checkUser(dfe, User.Role.normal); AddTissueTypeRequest request = arg(dfe, "request", AddTissueTypeRequest.class); logRequest("addTissueType", user, request); - return tissueTypeService.perform(request); + return tissueTypeService.performAddTissueType(request); + }; + } + + public DataFetcher addSpatialLocations() { + return dfe -> { + User user = checkUser(dfe, User.Role.normal); + AddTissueTypeRequest request = arg(dfe, "request", AddTissueTypeRequest.class); + logRequest("addSpatialLocations", user, request); + return tissueTypeService.performAddSpatialLocations(request); }; } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java index 6a702aa4..6b7a0321 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java @@ -206,6 +206,7 @@ private RuntimeWiring buildWiring() { .dataFetcher("updateWorkDnapStudy", transact(graphQLMutation.updateWorkDnapStudy())) .dataFetcher("updateDnapStudies", graphQLMutation.updateDnapStudies()) // transacted internally .dataFetcher("addTissueType", transact(graphQLMutation.addTissueType())) + .dataFetcher("addSpatialLocations", transact(graphQLMutation.addSpatialLocations())) .dataFetcher("stain", transact(graphQLMutation.stain())) .dataFetcher("unrelease", transact(graphQLMutation.unrelease())) .dataFetcher("recordStainResult", transact(graphQLMutation.recordStainResult())) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeService.java index 4b113666..3e08845a 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeService.java @@ -10,5 +10,12 @@ public interface TissueTypeService { * @param request specification of new tissue type * @return the new tissue type */ - TissueType perform(AddTissueTypeRequest request); + TissueType performAddTissueType(AddTissueTypeRequest request); + + /** + * Adds spatial locations to an existing tissue type + * @param request specification of new spatial locations + * @return the updated tissue type + */ + TissueType performAddSpatialLocations(AddTissueTypeRequest request); } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeServiceImp.java index 313295de..4714e21e 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/TissueTypeServiceImp.java @@ -13,8 +13,7 @@ import java.util.*; import java.util.function.Consumer; -import static uk.ac.sanger.sccp.utils.BasicUtils.asList; -import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; +import static uk.ac.sanger.sccp.utils.BasicUtils.*; /** * @author dr6 @@ -24,6 +23,7 @@ public class TissueTypeServiceImp implements TissueTypeService { private final TissueTypeRepo ttRepo; private final SpatialLocationRepo slRepo; private final Validator ttNameValidator, ttCodeValidator, slNameValidator; + private final Comparator SL_ORDER = Comparator.comparing(SpatialLocation::getCode); @Autowired public TissueTypeServiceImp(TissueTypeRepo ttRepo, SpatialLocationRepo slRepo, @@ -38,13 +38,24 @@ public TissueTypeServiceImp(TissueTypeRepo ttRepo, SpatialLocationRepo slRepo, } @Override - public TissueType perform(AddTissueTypeRequest request) { + public TissueType performAddTissueType(AddTissueTypeRequest request) { sanitise(request); - Set problems = validate(request); + Set problems = validateAddTissueType(request); if (!problems.isEmpty()) { throw new ValidationException(problems); } - return execute(request); + return executeAddTissueType(request); + } + + @Override + public TissueType performAddSpatialLocations(AddTissueTypeRequest request) { + sanitise(request); + Set problems = new LinkedHashSet<>(); + TissueType tt = validateAddSpatialLocations(problems, request); + if (!problems.isEmpty()) { + throw new ValidationException(problems); + } + return executeAddSpatialLocations(tt, request.getSpatialLocations()); } /** @@ -77,7 +88,7 @@ public void sanitise(AddTissueTypeRequest request) { * @param request the request to validate * @return descriptions of problems found */ - public Set validate(AddTissueTypeRequest request) { + public Set validateAddTissueType(AddTissueTypeRequest request) { Set problems = new LinkedHashSet<>(); if (request==null) { problems.add("No request supplied."); @@ -91,6 +102,35 @@ public Set validate(AddTissueTypeRequest request) { return problems; } + /** + * Checks for problems with the request. + * @param problems receptacle for problems found + * @param request the request to validate + * @return existing tissue type specified + */ + public TissueType validateAddSpatialLocations(Collection problems, AddTissueTypeRequest request) { + if (request==null) { + problems.add("No request supplied."); + return null; + } + validateSpatialLocations(problems, request.getSpatialLocations()); + TissueType tt = loadTissueType(problems, request.getName()); + checkExistingSpatialLocations(problems, tt, request.getSpatialLocations()); + return tt; + } + + /** Loads an existing tissue type by name. */ + public TissueType loadTissueType(Collection problems, String name) { + if (name!=null) { + Optional optTt = ttRepo.findByName(name); + if (optTt.isPresent()) { + return optTt.get(); + } + problems.add("Unknown tissue type name: " + repr(name)); + } + return null; + } + /** * Checks the spatial locations for problems. * @param problems receptacle for problems found @@ -132,17 +172,69 @@ public void checkExistingTissueTypes(final Collection problems, String n } } + /** + * Checks if new spatial locations class with existing ones + * @param problems receptacle for problems + * @param tt the tissue type the spatial locations belong to + * @param newSls details of new spatial locations + */ + public void checkExistingSpatialLocations(final Collection problems, TissueType tt, + List newSls) { + if (tt==null || nullOrEmpty(tt.getSpatialLocations()) || nullOrEmpty(newSls)) { + return; + } + Set newNamesUC = new HashSet<>(newSls.size()); + Set newCodes = new HashSet<>(newSls.size()); + for (NewSpatialLocation sl : newSls) { + if (!nullOrEmpty(sl.getName())) { + newNamesUC.add(sl.getName().toUpperCase()); + } + if (sl.getCode() >= 0) { + newCodes.add(sl.getCode()); + } + } + for (SpatialLocation sl : tt.getSpatialLocations()) { + if (newNamesUC.contains(sl.getName().toUpperCase())) { + problems.add("Spatial location already exists: "+sl.getName()); + } + if (newCodes.contains(sl.getCode())) { + problems.add("Spatial location code already in use: "+sl.getCode()); + } + } + } + /** * Creates the new tissue type and spatial locations * @param request specification of the new tissue type * @return the new tissue type */ - public TissueType execute(final AddTissueTypeRequest request) { + public TissueType executeAddTissueType(final AddTissueTypeRequest request) { TissueType tissueType = ttRepo.save(new TissueType(null, request.getName(), request.getCode())); List sls = request.getSpatialLocations().stream() .map(nsl -> new SpatialLocation(null, nsl.getName(), nsl.getCode(), tissueType)) + .sorted(SL_ORDER) .toList(); tissueType.setSpatialLocations(asList(slRepo.saveAll(sls))); return tissueType; } + + /** + * Creates spatial locations for an existing tissue type + * @param tt existing tissue type + * @param newSls details of new spatial locations + * @return updated tissue type + */ + public TissueType executeAddSpatialLocations(final TissueType tt, final List newSls) { + List sls = newSls.stream() + .map(nsl -> new SpatialLocation(null, nsl.getName(), nsl.getCode(), tt)) + .sorted(SL_ORDER) + .toList(); + List existingSls = coalesce(tt.getSpatialLocations(), List.of()); + ArrayList combinedSpatialLocations = new ArrayList<>(existingSls.size() + sls.size()); + combinedSpatialLocations.addAll(existingSls); + combinedSpatialLocations.addAll(asList(slRepo.saveAll(sls))); + combinedSpatialLocations.sort(SL_ORDER); + tt.setSpatialLocations(combinedSpatialLocations); + return tt; + } } diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index fa4487e5..ebfec30b 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -2101,6 +2101,14 @@ input AddTissueTypeRequest { spatialLocations: [AddTissueTypeSpatialLocation!]! } +"""Request to add spatial locations to an existing tissue type.""" +input AddSpatialLocationsRequest { + """The name of an existing tissue type.""" + name: String! + """The new spatial locations.""" + spatialLocations: [AddTissueTypeSpatialLocation!]! +} + """A spatial location for a new tissue type.""" input AddTissueTypeSpatialLocation { """The int code for the spatial location.""" @@ -2398,6 +2406,8 @@ type Mutation { updateDnapStudies: [DnapStudy!]! """Add a new tissue type.""" addTissueType(request: AddTissueTypeRequest!): TissueType! + """Add spatial location to an existing tissue type.""" + addSpatialLocations(request: AddSpatialLocationsRequest!): TissueType! """Record a new stain with time measurements.""" stain(request: StainRequest!): OperationResult! diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestAddTissueTypeMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestAddTissueTypeMutation.java index 79128403..87e7c0cd 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestAddTissueTypeMutation.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestAddTissueTypeMutation.java @@ -38,7 +38,7 @@ public class TestAddTissueTypeMutation { @Test @Transactional - public void testCleanOut() throws Exception { + public void testAddTissueType() throws Exception { User user = entityCreator.createUser("user1"); tester.setUser(user); String mutation = tester.readGraphQL("addtissuetype.graphql"); @@ -62,4 +62,32 @@ public void testCleanOut() throws Exception { assertEquals(i==0 ? "No spatial information" : "SL1", sl.getName()); } } + + @Test + @Transactional + public void testAddSpatialLocations() throws Exception { + User user = entityCreator.createUser("user1"); + tester.setUser(user); + ttRepo.save(new TissueType(null, "Bananas", "BAN")); + String mutation = tester.readGraphQL("addspatiallocations.graphql"); + Object response = tester.post(mutation); + Map ttData = chainGet(response, "data", "addSpatialLocations"); + assertEquals("Bananas", ttData.get("name")); + List> slsData = chainGet(ttData, "spatialLocations"); + assertThat(slsData).hasSize(2); + assertThat(slsData).containsExactly(Map.of("code", 0, "name", "No spatial information"), + Map.of("code", 1, "name", "SL1")); + TissueType tt = ttRepo.findByName("Bananas").orElseThrow(); + assertNotNull(tt.getId()); + assertEquals("Bananas", tt.getName()); + assertEquals("BAN", tt.getCode()); + List sls = tt.getSpatialLocations(); + assertThat(sls).hasSize(2); + for (int i = 0; i < sls.size(); i++) { + SpatialLocation sl = sls.get(i); + assertNotNull(sl.getId()); + assertEquals(i, sl.getCode()); + assertEquals(i==0 ? "No spatial information" : "SL1", sl.getName()); + } + } } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/TestTissueTypeService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/TestTissueTypeService.java index d5aafd45..e4dec073 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/TestTissueTypeService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/TestTissueTypeService.java @@ -53,21 +53,50 @@ void cleanup() throws Exception { @ParameterizedTest @ValueSource(booleans={true, false}) - void testPerform(boolean valid) { + void testPerformAddTissueType(boolean valid) { AddTissueTypeRequest request = new AddTissueTypeRequest("Bananas", "BNS"); doNothing().when(service).sanitise(any()); Set problems = valid ? Set.of() : Set.of("Bad thing."); - doReturn(problems).when(service).validate(any()); + doReturn(problems).when(service).validateAddTissueType(any()); if (valid) { TissueType newTissueType = EntityFactory.getTissueType(); - doReturn(newTissueType).when(service).execute(any()); - assertSame(newTissueType, service.perform(request)); + doReturn(newTissueType).when(service).executeAddTissueType(any()); + assertSame(newTissueType, service.performAddTissueType(request)); } else { - assertValidationException(() -> service.perform(request), problems); + assertValidationException(() -> service.performAddTissueType(request), problems); } verify(service).sanitise(request); - verify(service).validate(request); - verify(service, times(valid ? 1 : 0)).execute(request); + verify(service).validateAddTissueType(request); + if (valid) { + verify(service).executeAddTissueType(request); + } else { + verify(service, never()).executeAddTissueType(any()); + } + } + + @ParameterizedTest + @ValueSource(booleans={true, false}) + void testPerformAddSpatialLocations(boolean valid) { + AddTissueTypeRequest request = new AddTissueTypeRequest("Bananas", null); + request.setSpatialLocations(List.of(new NewSpatialLocation(0, "SL0"))); + doNothing().when(service).sanitise(any()); + final String problem = valid ? null : "Bad thing."; + Set problems = valid ? Set.of() : Set.of(problem); + TissueType tt = valid ? EntityFactory.getTissueType() : null; + mayAddProblem(problem, tt).when(service).validateAddSpatialLocations(any(), any()); + if (valid) { + doReturn(tt).when(service).executeAddSpatialLocations(any(), any()); + assertSame(tt, service.performAddSpatialLocations(request)); + } else { + assertValidationException(() -> service.performAddSpatialLocations(request), problems); + } + verify(service).sanitise(request); + verify(service).validateAddSpatialLocations(any(), same(request)); + if (valid) { + verify(service).executeAddSpatialLocations(tt, request.getSpatialLocations()); + } else { + verify(service, never()).executeAddSpatialLocations(any(), any()); + } } @Test @@ -105,13 +134,13 @@ void testSanitise_sls() { } @Test - void testValidate_null() { - Set problems = service.validate(null); + void testValidateAddTissueType_null() { + Set problems = service.validateAddTissueType(null); assertProblem(problems, "No request supplied."); } @Test - void testValidate_problems() { + void testValidateAddTissueType_problems() { when(mockTtNameValidator.validate(any(), any())).then(invocation -> { String name = invocation.getArgument(0); Consumer consumer = invocation.getArgument(1); @@ -129,7 +158,7 @@ void testValidate_problems() { AddTissueTypeRequest request = new AddTissueTypeRequest("Alpha", "ALP", List.of(new NewSpatialLocation(0, "sl0"))); - Collection problems = service.validate(request); + Collection problems = service.validateAddTissueType(request); verify(mockTtNameValidator).validate(eq("Alpha"), any()); verify(mockTtCodeValidator).validate(eq("ALP"), any()); verify(service).validateSpatialLocations(any(), same(request.getSpatialLocations())); @@ -139,14 +168,14 @@ void testValidate_problems() { } @Test - void testValidate_noProblems() { + void testValidateAddTissueType_noProblems() { when(mockTtNameValidator.validate(any(), any())).thenReturn(true); when(mockTtCodeValidator.validate(any(), any())).thenReturn(true); doNothing().when(service).validateSpatialLocations(any(), any()); doNothing().when(service).checkExistingTissueTypes(any(), any(), any()); AddTissueTypeRequest request = new AddTissueTypeRequest("Alpha", "ALP", List.of(new NewSpatialLocation(0, "sl0"))); - Collection problems = service.validate(request); + Collection problems = service.validateAddTissueType(request); verify(mockTtNameValidator).validate(eq("Alpha"), any()); verify(mockTtCodeValidator).validate(eq("ALP"), any()); verify(service).validateSpatialLocations(any(), same(request.getSpatialLocations())); @@ -154,6 +183,44 @@ void testValidate_noProblems() { assertThat(problems).isEmpty(); } + @Test + void testValidateAddSpatialLocations_null() { + List problems = new ArrayList<>(1); + assertNull(service.validateAddSpatialLocations(problems, null)); + assertProblem(problems, "No request supplied."); + } + + @Test + void testValidateAddSpatialLocations_problems() { + AddTissueTypeRequest request = new AddTissueTypeRequest("Alpha", null, List.of(new NewSpatialLocation(0, "sl0"))); + + mayAddProblem("Bad SL").when(service).validateSpatialLocations(any(), any()); + mayAddProblem("Bad tt", null).when(service).loadTissueType(any(), any()); + mayAddProblem("Clash").when(service).checkExistingSpatialLocations(any(), any(), any()); + List problems = new ArrayList<>(3); + service.validateAddSpatialLocations(problems, request); + verify(service).validateSpatialLocations(any(), same(request.getSpatialLocations())); + verify(service).loadTissueType(any(), eq("Alpha")); + verify(service).checkExistingSpatialLocations(any(), isNull(), same(request.getSpatialLocations())); + assertThat(problems).containsExactlyInAnyOrder("Bad SL", "Bad tt", "Clash"); + } + + @Test + void testValidateAddSpatialLocations_noProblems() { + AddTissueTypeRequest request = new AddTissueTypeRequest("Alpha", null, List.of(new NewSpatialLocation(0, "sl0"))); + + doNothing().when(service).validateSpatialLocations(any(), any()); + TissueType tt = EntityFactory.getTissueType(); + doReturn(tt).when(service).loadTissueType(any(), any()); + doNothing().when(service).checkExistingSpatialLocations(any(), any(), any()); + List problems = new ArrayList<>(0); + service.validateAddSpatialLocations(problems, request); + verify(service).validateSpatialLocations(any(), same(request.getSpatialLocations())); + verify(service).loadTissueType(any(), eq("Alpha")); + verify(service).checkExistingSpatialLocations(any(), same(tt), same(request.getSpatialLocations())); + assertThat(problems).isEmpty(); + } + @Test public void testValidateSpatialLocations_null() { List problems = new ArrayList<>(1); @@ -202,6 +269,17 @@ public void testValidateSpatialLocations_invalid() { "Spatial locations cannot contain duplicate codes.", "Bad sl name: sl3!"); } + @ParameterizedTest + @ValueSource(booleans = {false,true}) + public void testLoadTissueType(boolean found) { + List problems = new ArrayList<>(found ? 0 : 1); + TissueType tt = found ? EntityFactory.getTissueType() : null; + when(mockTtRepo.findByName(any())).thenReturn(Optional.ofNullable(tt)); + assertSame(tt, service.loadTissueType(problems, "Bananas")); + verify(mockTtRepo).findByName("Bananas"); + assertProblem(problems, found ? null : "Unknown tissue type name: \"Bananas\""); + } + @Test public void testCheckExistingTissueTypes_null() { List problems = new ArrayList<>(0); @@ -228,8 +306,63 @@ public void testCheckExistingTissueTypes(String match) { assertProblem(problems, expectedProblem); } + @ParameterizedTest + @ValueSource(strings={"tt", "ttsl", "sl"}) + public void testCheckExistingSpatialLocations_null(String match) { + TissueType tt; + List newSls; + switch (match) { + case "tt" -> { + tt = null; + newSls = List.of(new NewSpatialLocation(0, "sl0")); + } + case "ttsl" -> { + tt = new TissueType(10, "Alpha", "ALP"); + newSls = List.of(new NewSpatialLocation(0, "sl0")); + } + default -> { + tt = EntityFactory.getTissueType(); + newSls = null; + } + } + List problems = new ArrayList<>(0); + service.checkExistingSpatialLocations(problems, tt, newSls); + assertThat(problems).isEmpty(); + } + @Test - public void testExecute() { + void testCheckExistingSpatialLocations_valid() { + TissueType tt = new TissueType(10, "Alpha", "ALP"); + tt.setSpatialLocations(List.of( + new SpatialLocation(100, "sl0", 0, tt), + new SpatialLocation(101, "sl1", 1, tt) + )); + List newSls = List.of(new NewSpatialLocation(2, "sl2"), new NewSpatialLocation(3, "sl3")); + List problems = new ArrayList<>(0); + service.checkExistingSpatialLocations(problems, tt, newSls); + assertThat(problems).isEmpty(); + } + + @Test + void testCheckExistingSpatialLocations_problems() { + TissueType tt = new TissueType(10, "Alpha", "ALP"); + tt.setSpatialLocations(List.of( + new SpatialLocation(100, "sl0", 0, tt), + new SpatialLocation(101, "sl1", 1, tt) + )); + List newSls = List.of(new NewSpatialLocation(2, "sl2"), new NewSpatialLocation(3, "sl3"), + new NewSpatialLocation(4, "SL0"), new NewSpatialLocation(5, "sl1"), new NewSpatialLocation(0, "sl6")); + Collection problems = new HashSet<>(3); + service.checkExistingSpatialLocations(problems, tt, newSls); + assertThat(problems).containsExactlyInAnyOrder( + "Spatial location already exists: sl0", + "Spatial location already exists: sl1", + "Spatial location code already in use: 0" + ); + } + + @Test + public void testExecuteAddTissueType() { AddTissueTypeRequest request = new AddTissueTypeRequest("Alpha", "ALP", List.of(new NewSpatialLocation(0, "sl0"), new NewSpatialLocation(1, "sl1"))); @@ -249,10 +382,42 @@ public void testExecute() { expected.setSpatialLocations(List.of(new SpatialLocation(100, "sl0", 0, expected), new SpatialLocation(101, "sl1", 1, expected))); - TissueType result = service.execute(request); + TissueType result = service.executeAddTissueType(request); assertEquals(expected, result); assertEquals(expected.getSpatialLocations(), result.getSpatialLocations()); verify(mockTtRepo).save(result); verify(mockSlRepo).saveAll(result.getSpatialLocations()); } + + @Test + public void testExecuteAddSpatialLocations() { + TissueType tt = new TissueType(10, "Alpha", "ALP"); + tt.setSpatialLocations(List.of( + new SpatialLocation(100, "sl0", 0, tt), + new SpatialLocation(101, "sl3", 3, tt) + )); + List newSls = List.of( + new NewSpatialLocation(2, "sl2"), + new NewSpatialLocation(1, "sl1") + ); + when(mockSlRepo.saveAll(any())).then(invocation -> { + List sls = invocation.getArgument(0); + for (int i = 0; i < sls.size(); ++i) { + sls.get(i).setId(200+i); + } + return sls; + }); + TissueType result = service.executeAddSpatialLocations(tt, newSls); + assertEquals(10, result.getId()); + assertEquals("Alpha", result.getName()); + assertEquals("ALP", result.getCode()); + assertThat(result.getSpatialLocations()).containsExactly( + new SpatialLocation(100, "sl0", 0, result), + new SpatialLocation(200, "sl1", 1, result), + new SpatialLocation(201, "sl2", 2, result), + new SpatialLocation(101, "sl3", 3, result) + ); + verify(mockSlRepo).saveAll(List.of(new SpatialLocation(200, "sl1", 1, result), + new SpatialLocation(201, "sl2", 2, result))); + } } \ No newline at end of file diff --git a/src/test/resources/graphql/addspatiallocations.graphql b/src/test/resources/graphql/addspatiallocations.graphql new file mode 100644 index 00000000..4272796b --- /dev/null +++ b/src/test/resources/graphql/addspatiallocations.graphql @@ -0,0 +1,18 @@ +mutation { + addSpatialLocations(request: { + name: "Bananas" + spatialLocations: [{ + code: 0 + name: "No spatial information" + }, { + code: 1 + name: " SL1 " + }] + }) { + name + spatialLocations { + code + name + } + } +} \ No newline at end of file From 10e60c198c79659d8583ddd50216c62576cfbc71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Apr 2025 15:25:56 +0000 Subject: [PATCH 15/37] Bump org.apache.poi:poi-ooxml from 5.3.0 to 5.4.0 Bumps org.apache.poi:poi-ooxml from 5.3.0 to 5.4.0. --- updated-dependencies: - dependency-name: org.apache.poi:poi-ooxml dependency-version: 5.4.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 504c3221..56201484 100644 --- a/pom.xml +++ b/pom.xml @@ -208,7 +208,7 @@ org.apache.poi poi-ooxml - 5.3.0 + 5.4.0 From 233575df1ca1a2f336487c210ac18b74dbc86064 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 11 Apr 2025 10:33:56 +0100 Subject: [PATCH 16/37] x1309 Section reg: error message for prebarcode/external mismatch Add an explicit problem message when section registration has rows with the same prebarcode (Xenium barcode) and different external slide IDs. Previously the error message would only be "repeated barcode: [...]" --- .../register/SectionRegisterValidation.java | 22 +++++++++++++ .../TestSectionRegisterValidation.java | 32 +++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/register/SectionRegisterValidation.java b/src/main/java/uk/ac/sanger/sccp/stan/service/register/SectionRegisterValidation.java index 32ec2bf6..42b1200f 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/register/SectionRegisterValidation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/register/SectionRegisterValidation.java @@ -224,6 +224,7 @@ public void validateBarcodes(UCMap lwTypes) { boolean missing = false; BiConsumer bcProblem = (problem, bc) -> bcProblemMap.computeIfAbsent(problem, k -> new LinkedHashSet<>()).add(bc); + checkForPrebarcodeMismatch(); for (var lw : request.getLabware()) { String bc = lw.getExternalBarcode(); if (nullOrEmpty(bc)) { @@ -272,6 +273,27 @@ public void validateBarcodes(UCMap lwTypes) { } } + /** Add an explicit error message for disagreement between external barcode and prebarcode */ + public void checkForPrebarcodeMismatch() { + Set seenPrebarcodes = new HashSet<>(request.getLabware().size()); + boolean foundDupe = false; + boolean isXenium = false; + for (var lw : request.getLabware()) { + String bc = lw.getPreBarcode(); + if (!nullOrEmpty(bc) && !seenPrebarcodes.add(bc.toUpperCase())) { + foundDupe = true; + isXenium = (lw.getLabwareType()!=null && lw.getLabwareType().equalsIgnoreCase("xenium")); + break; + } + } + if (foundDupe) { + String prebarcodeDesc = isXenium ? "Xenium barcode" : "prebarcode"; + addProblem("Entries referring to the same labware should have the same external slide ID and " + + "the same %s. Entries referring to different labware should have different external " + + "slide ID and different %s.", prebarcodeDesc, prebarcodeDesc); + } + } + // Yikes, this method is big. public UCMap validateTissues(UCMap donorMap) { UCMap hmdmcMap = loadAllFromSectionsToStringMap(request, SectionRegisterContent::getHmdmc, diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/register/TestSectionRegisterValidation.java b/src/test/java/uk/ac/sanger/sccp/stan/service/register/TestSectionRegisterValidation.java index 60753687..a2de4cc1 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/register/TestSectionRegisterValidation.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/register/TestSectionRegisterValidation.java @@ -28,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import static uk.ac.sanger.sccp.stan.EntityFactory.objToCollection; +import static uk.ac.sanger.sccp.stan.Matchers.assertProblem; import static uk.ac.sanger.sccp.utils.BasicUtils.*; /** @@ -401,6 +402,7 @@ public void testValidateBarcodes(List xbs, List pbs, String ltNa SectionRegisterValidation validation = makeValidation(request); validation.validateBarcodes(lwTypes); + verify(validation).checkForPrebarcodeMismatch(); assertThat(validation.getProblems()).containsExactlyInAnyOrderElementsOf(nullToEmpty(expectedProblems)); } @@ -421,7 +423,9 @@ static Stream validateBarcodesArgs() { {List.of("Alpha"), List.of("!Beta"), "Xenium", List.of("Bad xenium barcode: !Beta")}, {List.of("Alpha", "Beta", "ALPHA"), null, "lt", List.of("Repeated barcode: [ALPHA]")}, {List.of("Alpha", "Beta", "Gamma", "Delta", "Epsilon"), List.of("Alpha", "Alaska", "", "Beta", "ALASKA"), "xenium", - List.of("Repeated barcodes: [Beta, ALASKA]")}, + List.of("Entries referring to the same labware should have the same external slide ID and the " + + "same Xenium barcode. Entries referring to different labware should have different " + + "external slide ID and different Xenium barcode.", "Repeated barcodes: [Beta, ALASKA]")}, }).map(arr -> { Object[] arr2 = Arrays.copyOf(arr, 5); arr2[4] = lwTypes; @@ -454,7 +458,7 @@ public void testFindBarcodeProblem(String barcode, String mode, String expectedP @ParameterizedTest @MethodSource("validateTissuesArgs") - public void testValidateTissues(ValidateTissueTestData testData) { + void testValidateTissues(ValidateTissueTestData testData) { when(mockHmdmcRepo.findAllByHmdmcIn(any())).then(findAllAnswer(testData.hmdmcs, Hmdmc::getHmdmc)); when(mockTissueTypeRepo.findAllByNameIn(any())).then(findAllAnswer(testData.tissueTypes, TissueType::getName)); when(mockFixativeRepo.findAllByNameIn(any())).then(findAllAnswer(testData.fixatives, Fixative::getName)); @@ -595,6 +599,30 @@ static Stream validateTissuesArgs() { ); } + @ParameterizedTest + @ValueSource(strings = {"xenium", "non-xenium", "ok"}) + void testCheckForPrebarcodeMismatch(String mode) { + String lt = mode.equalsIgnoreCase("xenium") ? "xenium" : "plate"; + List srls = List.of( + new SectionRegisterLabware("xb1", lt, List.of()), + new SectionRegisterLabware("xb2", lt, List.of()) + ); + srls.get(0).setPreBarcode("bc1"); + srls.get(1).setPreBarcode(mode.equalsIgnoreCase("ok") ? "bc2" : "BC1"); + var val = makeValidation(srls); + val.checkForPrebarcodeMismatch(); + String expectedProblem = switch (mode) { + case "xenium" -> "Entries referring to the same labware should have the same external slide ID and " + + "the same Xenium barcode. Entries referring to different labware should have different external " + + "slide ID and different Xenium barcode."; + case "non-xenium" -> "Entries referring to the same labware should have the same external slide ID and " + + "the same prebarcode. Entries referring to different labware should have different external " + + "slide ID and different prebarcode."; + default -> null; + }; + assertProblem(val.getProblems(), expectedProblem); + } + @ParameterizedTest @MethodSource("validateSamplesArgs") public void testValidateSamples(Object contentsObj, UCMap tissueMap, Object expectedProblemsObj, From fad09590e58f0a0bdb64ea6085584b06d35ce615 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:27:59 +0100 Subject: [PATCH 17/37] x1305 support a mutation to change the work of existing operations The operations will be unlinked from other works, and their sample/slot links will be removed where appropriate --- .../ac/sanger/sccp/stan/GraphQLMutation.java | 14 +- .../ac/sanger/sccp/stan/GraphQLProvider.java | 1 + .../sanger/sccp/stan/repo/OperationRepo.java | 32 ++- .../ac/sanger/sccp/stan/repo/ReleaseRepo.java | 32 ++- .../uk/ac/sanger/sccp/stan/repo/WorkRepo.java | 6 + .../sccp/stan/request/OpWorkRequest.java | 64 +++++ .../service/workchange/WorkChangeData.java | 12 + .../service/workchange/WorkChangeService.java | 13 + .../workchange/WorkChangeServiceImp.java | 221 ++++++++++++++++ .../WorkChangeValidationService.java | 19 ++ .../WorkChangeValidationServiceImp.java | 127 +++++++++ src/main/resources/schema.graphqls | 12 +- .../TestSetOperationWorkMutation.java | 206 +++++++++++++++ .../sccp/stan/repo/TestOperationRepo.java | 23 +- .../sccp/stan/repo/TestReleaseRepo.java | 20 +- .../workchange/TestWorkChangeService.java | 247 ++++++++++++++++++ .../TestWorkChangeValidationService.java | 192 ++++++++++++++ .../graphql/setoperationwork.graphql | 8 + 18 files changed, 1238 insertions(+), 11 deletions(-) create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/request/OpWorkRequest.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeData.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeService.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeValidationService.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeValidationServiceImp.java create mode 100644 src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java create mode 100644 src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java create mode 100644 src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeValidationService.java create mode 100644 src/test/resources/graphql/setoperationwork.graphql diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java index 3319875d..0af335b2 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java @@ -28,6 +28,7 @@ import uk.ac.sanger.sccp.stan.service.register.IRegisterService; import uk.ac.sanger.sccp.stan.service.work.WorkService; import uk.ac.sanger.sccp.stan.service.work.WorkTypeService; +import uk.ac.sanger.sccp.stan.service.workchange.WorkChangeService; import java.util.List; import java.util.function.BiFunction; @@ -105,6 +106,7 @@ public class GraphQLMutation extends BaseGraphQLResource { final UserAdminService userAdminService; final SlotCopyRecordService slotCopyRecordService; final TissueTypeService tissueTypeService; + final WorkChangeService workChangeService; @Autowired public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authComp, @@ -139,7 +141,7 @@ public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authCo ReactivateService reactivateService, LibraryPrepService libraryPrepService, SegmentationService segmentationService, CleanOutService cleanOutService, RoiMetricService roiMetricService, UserAdminService userAdminService, SlotCopyRecordService slotCopyRecordService, - TissueTypeService tissueTypeService) { + TissueTypeService tissueTypeService, WorkChangeService workChangeService) { super(objectMapper, authComp, userRepo); this.authService = authService; this.registerService = registerService; @@ -205,6 +207,7 @@ public GraphQLMutation(ObjectMapper objectMapper, AuthenticationComponent authCo this.userAdminService = userAdminService; this.slotCopyRecordService = slotCopyRecordService; this.tissueTypeService = tissueTypeService; + this.workChangeService = workChangeService; } private void logRequest(String name, User user, Object request) { @@ -933,6 +936,15 @@ public DataFetcher saveSlotCopy() { }; } + public DataFetcher> setOperationWork() { + return dfe -> { + User user = checkUser(dfe, User.Role.normal); + OpWorkRequest request = arg(dfe, "request", OpWorkRequest.class); + logRequest("setOperationWork", user, request); + return workChangeService.perform(request); + }; + } + public DataFetcher addTissueType() { return dfe -> { User user = checkUser(dfe, User.Role.normal); diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java index 6b7a0321..6741690f 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLProvider.java @@ -239,6 +239,7 @@ private RuntimeWiring buildWiring() { .dataFetcher("cleanOut", transact(graphQLMutation.cleanOut())) .dataFetcher("recordSampleMetrics", transact(graphQLMutation.recordSampleMetrics())) .dataFetcher("saveSlotCopy", transact(graphQLMutation.saveSlotCopy())) + .dataFetcher("setOperationWork", transact(graphQLMutation.setOperationWork())) .dataFetcher("addUser", transact(graphQLMutation.addUser())) .dataFetcher("setUserRole", transact(graphQLMutation.setUserRole())) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/repo/OperationRepo.java b/src/main/java/uk/ac/sanger/sccp/stan/repo/OperationRepo.java index 5d8917ab..5ff61a12 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/repo/OperationRepo.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/repo/OperationRepo.java @@ -2,11 +2,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; -import uk.ac.sanger.sccp.stan.model.Operation; -import uk.ac.sanger.sccp.stan.model.OperationType; +import uk.ac.sanger.sccp.stan.model.*; -import java.util.Collection; -import java.util.List; +import java.util.*; public interface OperationRepo extends CrudRepository { @Query("select distinct op from Operation op join Action a on (a.operationId=op.id) " + @@ -26,4 +24,30 @@ public interface OperationRepo extends CrudRepository { List findAllByOperationTypeAndDestinationSlotIdIn(OperationType opType, Collection slotIds); List findAllByOperationType(OperationType opType); + + @Query(value = "select distinct a.operation_id, a.dest_slot_id, a.sample_id " + + "from action a " + + "where a.operation_id in (?1)", nativeQuery = true) + int[][] _loadOpSlotSampleIds(Collection opIds); + + /** + * Gets the slot and sample ids for each each specified operation + * @param opIds operation ids to look up + * @return a map of operation id to the associated slot and sample ids + */ + default Map> findOpSlotSampleIds(Collection opIds) { + if (opIds.isEmpty()) { + return Map.of(); + } + int[][] ossis = _loadOpSlotSampleIds(opIds); + if (ossis==null || ossis.length==0) { + return Map.of(); + } + Map> opSlotSampleIds = new HashMap<>(); + for (int[] ossi : ossis) { + opSlotSampleIds.computeIfAbsent(ossi[0], k -> new HashSet<>()) + .add(new SlotIdSampleId(ossi[1], ossi[2])); + } + return opSlotSampleIds; + } } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/repo/ReleaseRepo.java b/src/main/java/uk/ac/sanger/sccp/stan/repo/ReleaseRepo.java index 91606a0a..5fe805df 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/repo/ReleaseRepo.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/repo/ReleaseRepo.java @@ -1,11 +1,12 @@ package uk.ac.sanger.sccp.stan.repo; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import uk.ac.sanger.sccp.stan.model.Release; +import uk.ac.sanger.sccp.stan.model.SlotIdSampleId; import javax.persistence.EntityNotFoundException; -import java.util.Collection; -import java.util.List; +import java.util.*; /** * @author dr6 @@ -25,4 +26,31 @@ default List getAllByIdIn(Collection ids) throws EntityNotFoun return RepoUtils.getAllByField(this::findAllByIdIn, ids, Release::getId, "Unknown release ID{s}: ", null); } + + @Query(value = "select r.id, se.slot_id, se.sample_id " + + "from labware_release r " + + "join snapshot_element se on (r.snapshot_id=se.snapshot_id) " + + "where r.id in (?1)", nativeQuery = true) + int[][] _loadReleaseSlotSampleIds(Collection releaseIds); + + /** + * Gets the slot and sample ids for each each specified release + * @param releaseIds release ids to look up + * @return a map of release id to the associated slot and sample ids + */ + default Map> findReleaseSlotSampleIds(Collection releaseIds) { + if (releaseIds.isEmpty()) { + return Map.of(); + } + int[][] rssis = _loadReleaseSlotSampleIds(releaseIds); + if (rssis==null || rssis.length==0) { + return Map.of(); + } + Map> releaseSlotSampleIds = new HashMap<>(); + for (int[] rssi : rssis) { + releaseSlotSampleIds.computeIfAbsent(rssi[0], k -> new HashSet<>()) + .add(new SlotIdSampleId(rssi[1], rssi[2])); + } + return releaseSlotSampleIds; + } } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkRepo.java b/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkRepo.java index a076db17..0dc6e495 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkRepo.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkRepo.java @@ -5,6 +5,7 @@ import org.springframework.data.repository.CrudRepository; import uk.ac.sanger.sccp.stan.model.*; import uk.ac.sanger.sccp.stan.model.Work.Status; +import uk.ac.sanger.sccp.utils.UCMap; import javax.persistence.EntityNotFoundException; import java.util.*; @@ -181,4 +182,9 @@ default Set getSetByWorkNumberIn(Collection workNumbers) throws En } List findAllByWorkRequesterIn(Collection requesters); + + default UCMap getMapByWorkNumberIn(Collection workNumbers) throws EntityNotFoundException { + return RepoUtils.getUCMapByField(this::findAllByWorkNumberIn, workNumbers, Work::getWorkNumber, + "Missing work number{s} in database: "); + } } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/OpWorkRequest.java b/src/main/java/uk/ac/sanger/sccp/stan/request/OpWorkRequest.java new file mode 100644 index 00000000..2bd21d5e --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/OpWorkRequest.java @@ -0,0 +1,64 @@ +package uk.ac.sanger.sccp.stan.request; + +import java.util.List; +import java.util.Objects; + +import static uk.ac.sanger.sccp.utils.BasicUtils.describe; + +/** + * A request to alter the work linked to existing operations. + * @author dr6 + */ +public class OpWorkRequest { + private String workNumber; + private List opIds = List.of(); + + public OpWorkRequest() {} + + public OpWorkRequest(String workNumber, List opIds) { + setWorkNumber(workNumber); + setOpIds(opIds); + } + + /** The work number to link. */ + public String getWorkNumber() { + return this.workNumber; + } + + public void setWorkNumber(String workNumber) { + this.workNumber = workNumber; + } + + /** The operation ids to link to the work */ + public List getOpIds() { + return this.opIds; + } + + public void setOpIds(List opIds) { + this.opIds = opIds; + } + + @Override + public String toString() { + return describe(this) + .add("workNumber", workNumber) + .add("opIds", opIds) + .reprStringValues() + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || o.getClass() != this.getClass()) return false; + OpWorkRequest that = (OpWorkRequest) o; + return (Objects.equals(this.workNumber, that.workNumber) + && Objects.equals(this.opIds, that.opIds) + ); + } + + @Override + public int hashCode() { + return Objects.hash(workNumber, opIds); + } +} \ No newline at end of file diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeData.java b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeData.java new file mode 100644 index 00000000..253d7028 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeData.java @@ -0,0 +1,12 @@ +package uk.ac.sanger.sccp.stan.service.workchange; + +import uk.ac.sanger.sccp.stan.model.Operation; +import uk.ac.sanger.sccp.stan.model.Work; + +import java.util.List; + +/** + * Data loaded while processing a work change request + */ +public record WorkChangeData(Work work, List ops) { +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeService.java new file mode 100644 index 00000000..da688795 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeService.java @@ -0,0 +1,13 @@ +package uk.ac.sanger.sccp.stan.service.workchange; + +import uk.ac.sanger.sccp.stan.model.Operation; +import uk.ac.sanger.sccp.stan.request.OpWorkRequest; +import uk.ac.sanger.sccp.stan.service.ValidationException; + +import java.util.List; + +/** Service for changing work linked to prior events */ +public interface WorkChangeService { + /** Validate and perform the request. */ + List perform(OpWorkRequest request) throws ValidationException; +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java new file mode 100644 index 00000000..24b42915 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java @@ -0,0 +1,221 @@ +package uk.ac.sanger.sccp.stan.service.workchange; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.repo.*; +import uk.ac.sanger.sccp.stan.request.OpWorkRequest; +import uk.ac.sanger.sccp.stan.service.ValidationException; +import uk.ac.sanger.sccp.stan.service.work.WorkService; +import uk.ac.sanger.sccp.utils.UCMap; + +import java.util.*; + +import static java.util.stream.Collectors.toSet; +import static uk.ac.sanger.sccp.utils.BasicUtils.inMap; +import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; + +/** + * @author dr6 + */ +@Service +public class WorkChangeServiceImp implements WorkChangeService { + Logger log = LoggerFactory.getLogger(WorkChangeServiceImp.class); + + private final WorkChangeValidationService validationService; + private final WorkService workService; + private final WorkRepo workRepo; + private final OperationRepo opRepo; + private final ReleaseRepo releaseRepo; + + @Autowired + public WorkChangeServiceImp(WorkChangeValidationService validationService, WorkService workService, + WorkRepo workRepo, OperationRepo opRepo, ReleaseRepo releaseRepo) { + this.validationService = validationService; + this.workService = workService; + this.workRepo = workRepo; + this.opRepo = opRepo; + this.releaseRepo = releaseRepo; + } + + @Override + public List perform(OpWorkRequest request) throws ValidationException { + WorkChangeData data = validationService.validate(request); + return execute(data.work(), data.ops()); + } + + /** + * This is called after validation to perform the update + * @param work the work to link to the operations + * @param ops the operations + * @return the operations + */ + public List execute(Work work, List ops) { + clearOutPriorWorks(ops); + workService.link(work, ops); + return ops; + } + + /** + * Gets the works currently linked to each specified operation + * @param ops the operations to look up works for + * @return map from operation id to set of linked works + */ + public Map> loadOpIdWorks(List ops) { + List opIds = ops.stream().map(Operation::getId).toList(); + Map> opIdWorkNumbers = workRepo.findWorkNumbersForOpIds(opIds); + Set workNumbersToLoad = opIdWorkNumbers.values().stream() + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .collect(toSet()); + if (workNumbersToLoad.isEmpty()) { + return Map.of(); + } + UCMap workMap = workRepo.getMapByWorkNumberIn(workNumbersToLoad); + Map> opIdWorks = new HashMap<>(ops.size()); + for (Operation op : ops) { + Set wns = opIdWorkNumbers.get(op.getId()); + Set workSet; + if (nullOrEmpty(wns)) { + workSet = Set.of(); + } else { + workSet = wns.stream().map(workMap::get).collect(toSet()); + } + opIdWorks.put(op.getId(), workSet); + } + return opIdWorks; + } + + /** + * Finds slot/samples that a work should still be linked to without the indicated unlinked operations + * @param works the works to check + * @param excludedOpIds the operation ids to exclude + * @return map from work id to relevant slot/sample ids + */ + public Map> workExtantSlotSampleIds(Collection works, + Set excludedOpIds) { + // All the operation ids that the works are still linked to + Set otherOpIds = works.stream() + .filter(work -> !nullOrEmpty(work.getOperationIds())) + .flatMap(work -> work.getOperationIds().stream()) + .filter(opId -> !excludedOpIds.contains(opId)) + .collect(toSet()); + // All the release ids that the works are linked to + Set releaseIds = works.stream() + .filter(work -> !nullOrEmpty(work.getReleaseIds())) + .flatMap(work -> work.getReleaseIds().stream()) + .collect(toSet()); + // Find slotSampleIds for each operation (that is still linked to a work) + Map> otherOpSlotSampleIds = opRepo.findOpSlotSampleIds(otherOpIds); + Map> releaseSlotSampleIds = releaseRepo.findReleaseSlotSampleIds(releaseIds); + Map> workExtantSlotSampleIds = new HashMap<>(works.size()); + for (Work work : works) { + if (nullOrEmpty(work.getOperationIds()) && nullOrEmpty(work.getReleaseIds())) { + workExtantSlotSampleIds.put(work.getId(), Set.of()); + } else { + Set wssids = new HashSet<>(); + if (!nullOrEmpty(work.getOperationIds())) { + for (Integer opId : work.getOperationIds()) { + Set ssids = otherOpSlotSampleIds.get(opId); + if (!nullOrEmpty(ssids)) { + wssids.addAll(ssids); + } + } + } + if (!nullOrEmpty(work.getReleaseIds())) { + for (Integer releaseId : work.getReleaseIds()) { + Set rssids = releaseSlotSampleIds.get(releaseId); + if (!nullOrEmpty(rssids)) { + wssids.addAll(rssids); + } + } + } + workExtantSlotSampleIds.put(work.getId(), wssids); + } + } + + return workExtantSlotSampleIds; + } + + /** Gets the slot/samples that are targeted by a given operation */ + public Set getOpSlotSampleIds(Operation op) { + return op.getActions().stream() + .map(ac -> new SlotIdSampleId(ac.getDestination(), ac.getSample())) + .collect(toSet()); + } + + /** + * Finds the Work.SampleSlotIds that should be removed from each work + * @param ops the operations being unlinked + * @param workIdMap map to get works from work id + * @param opIdWorks map of operation id to linked works + * @return map from work id to the SampleSlotIds that should be removed from that work + */ + @NotNull + public Map> findSampleSlotIdsToRemove(List ops, Map workIdMap, + Map> opIdWorks) { + Map> extantWorkSsIds = workExtantSlotSampleIds(workIdMap.values(), opIdWorks.keySet()); + Map> workSsidsToRemove = new HashMap<>(); + + // Note that SlotIdSampleId and Work.SampleSlotId are distinct types with similar structures + for (Operation op : ops) { + if (!nullOrEmpty(opIdWorks.get(op.getId()))) { + Set opSlotSampleIds = getOpSlotSampleIds(op); + for (Work work : opIdWorks.get(op.getId())) { + Set workSsids = extantWorkSsIds.get(work.getId()); + var toRemove = opSlotSampleIds.stream() + .filter(ssid -> !workSsids.contains(ssid)) + .map(ss -> new Work.SampleSlotId(ss.getSampleId(), ss.getSlotId())) + .collect(toSet()); + workSsidsToRemove.computeIfAbsent(work.getId(), k -> new HashSet<>()).addAll(toRemove); + } + } + } + return workSsidsToRemove; + } + + /** + * For each operation: + *
    + *
  • Get its slotSampleIds X
  • + *
  • For each of its previously linked works:
      + *
    • Find the slotSampleIds it's linked to via other ops/releases Y
    • + *
    • We will want to remove from the work any elements of X + * that are not in Y
    • + *
  • + *
+ * @param ops operations having their works removed + */ + public void clearOutPriorWorks(List ops) { + Map> opIdWorks = loadOpIdWorks(ops); + if (opIdWorks.isEmpty()) { + return; // nothing to do + } + // A map from work id to work (the works having things removed) + Map workIdMap = opIdWorks.values().stream() + .flatMap(Collection::stream) + .distinct() + .collect(inMap(Work::getId)); + + Map> workSsidsToRemove = findSampleSlotIdsToRemove(ops, workIdMap, opIdWorks); + + log.info("WorkChange: Removing sample/slot ids from work ids, where present: {}", workSsidsToRemove); + + workSsidsToRemove.forEach((workId, toRemove) -> { + if (!nullOrEmpty(toRemove)) { + workIdMap.get(workId).getSampleSlotIds().removeAll(toRemove); + } + }); + + log.info("WorkChange: Removing operations {} from work ids {}", opIdWorks.keySet(), workIdMap.keySet()); + + // remove the affected op ids from the works + for (Work work : workIdMap.values()) { + work.getOperationIds().removeAll(opIdWorks.keySet()); + } + workRepo.saveAll(workIdMap.values()); + } +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeValidationService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeValidationService.java new file mode 100644 index 00000000..849eb94e --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeValidationService.java @@ -0,0 +1,19 @@ +package uk.ac.sanger.sccp.stan.service.workchange; + +import uk.ac.sanger.sccp.stan.request.OpWorkRequest; +import uk.ac.sanger.sccp.stan.service.ValidationException; + +/** + * Service for validating work change requests + * @author dr6 + */ +public interface WorkChangeValidationService { + /** + * Validates the request and returns relevant data loaded. + * Note that the operations returned exclude ones already linked to the indicated work. + * @param request the request to link work to existing ops + * @return the work and ops indicated by the request + * @exception ValidationException if the validation fails + */ + WorkChangeData validate(OpWorkRequest request) throws ValidationException; +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeValidationServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeValidationServiceImp.java new file mode 100644 index 00000000..90b6c85b --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeValidationServiceImp.java @@ -0,0 +1,127 @@ +package uk.ac.sanger.sccp.stan.service.workchange; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import uk.ac.sanger.sccp.stan.model.Operation; +import uk.ac.sanger.sccp.stan.model.Work; +import uk.ac.sanger.sccp.stan.repo.OperationRepo; +import uk.ac.sanger.sccp.stan.repo.WorkRepo; +import uk.ac.sanger.sccp.stan.request.OpWorkRequest; +import uk.ac.sanger.sccp.stan.service.ValidationException; + +import java.util.*; + +import static uk.ac.sanger.sccp.utils.BasicUtils.*; + +/** + * @author dr6 + */ +@Service +public class WorkChangeValidationServiceImp implements WorkChangeValidationService { + private final WorkRepo workRepo; + private final OperationRepo opRepo; + + @Autowired + public WorkChangeValidationServiceImp(WorkRepo workRepo, OperationRepo opRepo) { + this.workRepo = workRepo; + this.opRepo = opRepo; + } + + @Override + public WorkChangeData validate(OpWorkRequest request) throws ValidationException { + if (request==null) { + throw new ValidationException(List.of("No request supplied.")); + } + Collection problems = new LinkedHashSet<>(); + Work work = loadWork(problems, request.getWorkNumber()); + List ops; + if (nullOrEmpty(request.getOpIds())) { + problems.add("No operations specified."); + ops = List.of(); + } else { + ops = loadOps(problems, work, request.getOpIds()); + } + if (!problems.isEmpty()) { + throw new ValidationException(problems); + } + return new WorkChangeData(work, ops); + } + + /** Loads the indicated work. */ + public Work loadWork(Collection problems, String workNumber) { + if (nullOrEmpty(workNumber)) { + problems.add("No work number specified."); + return null; + } + Optional optWork = workRepo.findByWorkNumber(workNumber); + if (optWork.isEmpty()) { + problems.add("No work found with work number: " + repr(workNumber)); + return null; + } + return optWork.get(); + } + + /** + * Loads the indicated operations. + * Excludes ops already listed against the given work. + * @param problems receptacle for problems + * @param work the work under consideration (if successfully loaded) + * @param givenOpIds the given list of operation ids + * @return the loaded operations + */ + public List loadOps(Collection problems, Work work, List givenOpIds) { + List opIds = dedupe(problems, givenOpIds, "operation ID"); + if (work != null) { + if (!(opIds instanceof ArrayList)) { + // Make sure we have a mutable list + opIds = new ArrayList<>(opIds); + } + opIds.removeAll(work.getOperationIds()); + if (opIds.isEmpty()) { + problems.add(String.format("Specified operations are already linked to work %s.", work.getWorkNumber())); + return List.of(); + } + } + Map opMap = stream(opRepo.findAllById(opIds)).collect(inMap(Operation::getId)); + if (opMap.size() < opIds.size()) { + Set missing = new LinkedHashSet<>(opIds); + missing.remove(null); + missing.removeAll(opMap.keySet()); + if (!missing.isEmpty()) { + problems.add(pluralise("Unknown operation ID{s}: ", missing.size())+missing); + } + } + return opIds.stream().map(opMap::get).filter(Objects::nonNull).toList(); + } + + /** + * Checks for duplicates in the given collection and returns a deduplicated list. + * Adds a problem if a duplicate is found. + * @param problems receptacle for problems + * @param items the items to deduplicate + * @param itemName the name of the item to use in problem messages + * @return the deduplicated items + * @param the type of item + */ + public List dedupe(Collection problems, Collection items, String itemName) { + if (items.isEmpty()) { + return List.of(); + } + Set seen = new HashSet<>(items.size()); + List newList = new ArrayList<>(items.size()); + boolean anyNull = false; + for (E item : items) { + if (item==null) { + anyNull = true; + } else if (!seen.add(item)) { + problems.add("Repeated "+itemName+": "+item); + } else { + newList.add(item); + } + } + if (anyNull) { + problems.add("Missing "+itemName+"."); + } + return newList; + } +} diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index ebfec30b..af51b210 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -2109,7 +2109,7 @@ input AddSpatialLocationsRequest { spatialLocations: [AddTissueTypeSpatialLocation!]! } -"""A spatial location for a new tissue type.""" +"""A new spatial location for a particular tissue type.""" input AddTissueTypeSpatialLocation { """The int code for the spatial location.""" code: Int! @@ -2117,6 +2117,14 @@ input AddTissueTypeSpatialLocation { name: String! } +"""A request to alter the work linked to existing operations.""" +input OpWorkRequest { + """The work number to link.""" + workNumber: String! + """The IDs of the operations to update.""" + opIds: [Int!]! +} + """Info about the app version.""" type VersionInfo { """The output of git describe.""" @@ -2475,6 +2483,8 @@ type Mutation { recordSampleMetrics(request: SampleMetricsRequest!): OperationResult! """Save slot copy information for a future operation.""" saveSlotCopy(request: SlotCopySave!): SlotCopyLoad! + """Link a work number to prior operations.""" + setOperationWork(request: OpWorkRequest!): [Operation!]! """Create a new user for the application.""" addUser(username: String!): User! diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java new file mode 100644 index 00000000..caa9a6f5 --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java @@ -0,0 +1,206 @@ +package uk.ac.sanger.sccp.stan.integrationtest; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import uk.ac.sanger.sccp.stan.EntityCreator; +import uk.ac.sanger.sccp.stan.GraphQLTester; +import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.model.Work.SampleSlotId; +import uk.ac.sanger.sccp.stan.repo.*; + +import javax.persistence.EntityManager; +import javax.transaction.Transactional; +import java.util.*; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static uk.ac.sanger.sccp.stan.integrationtest.IntegrationTestUtils.chainGet; +import static uk.ac.sanger.sccp.utils.BasicUtils.hashSetOf; + +/** + * Tests setOperationWork mutation + * @author dr6 + */ +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +@Import({GraphQLTester.class, EntityCreator.class}) +public class TestSetOperationWorkMutation { + @Autowired + private GraphQLTester tester; + @Autowired + private EntityCreator entityCreator; + @Autowired + private EntityManager entityManager; + @Autowired + private OperationRepo opRepo; + @Autowired + private ActionRepo actionRepo; + @Autowired + private WorkRepo workRepo; + @Autowired + private SnapshotRepo snapshotRepo; + @Autowired + private SnapshotElementRepo snapshotElementRepo; + @Autowired + private ReleaseRepo releaseRepo; + + private OperationType opType; + private User user; + private Tissue tissue; + private ReleaseDestination releaseDestination; + private ReleaseRecipient releaseRecipient; + + private User getUser() { + if (user==null) { + user = entityCreator.createUser("user1"); + } + return user; + } + + private OperationType getOpType() { + if (opType==null) { + opType = entityCreator.createOpType("opname", null); + } + return opType; + } + + private Tissue getTissue() { + if (tissue==null) { + tissue = entityCreator.createTissue(null, null); + } + return tissue; + } + + private ReleaseDestination getReleaseDestination() { + if (releaseDestination==null) { + releaseDestination = entityCreator.createReleaseDestination("The Moon"); + } + return releaseDestination; + } + + private ReleaseRecipient getReleaseRecipient() { + if (releaseRecipient==null) { + releaseRecipient = entityCreator.createReleaseRecipient("jimmy"); + } + return releaseRecipient; + } + + private Sample[] makeSamples(int n) { + Tissue tissue = getTissue(); + return IntStream.rangeClosed(1,n).mapToObj(i -> entityCreator.createSample(tissue, i)).toArray(Sample[]::new); + } + + private Work[] makeWorks(int n) { + Work[] works = new Work[n]; + works[0] = entityCreator.createWork(null, null, null, null, null); + for (int i = 1; i < works.length; ++i) { + works[i] = entityCreator.createWorkLike(works[0]); + } + return works; + } + + private Operation makeOp(Object... args) { + OperationType opType = getOpType(); + User user = getUser(); + Operation op = opRepo.save(new Operation(null, opType, null, null, user)); + List actions = new ArrayList<>(args.length/2); + for (int i = 0; i < args.length; i+=2) { + Slot slot = (Slot) args[i]; + Sample sample = (Sample) args[i+1]; + Action action = new Action(null, op.getId(), slot, slot, sample, sample); + actions.add(action); + } + actionRepo.saveAll(actions); + entityManager.refresh(op); + return op; + } + + private Release makeRelease(Labware lw, Object... args) { + Snapshot snapshot = snapshotRepo.save(new Snapshot(lw.getId())); + List elements = new ArrayList<>(args.length/2); + for (int i = 0; i < args.length; i+=2) { + Slot slot = (Slot) args[i]; + Sample sample = (Sample) args[i+1]; + SnapshotElement el = new SnapshotElement(null, snapshot.getId(), slot.getId(), sample.getId()); + elements.add(el); + } + snapshotElementRepo.saveAll(elements); + entityManager.refresh(snapshot); + Release release = new Release(lw, getUser(), getReleaseDestination(), getReleaseRecipient(), snapshot.getId()); + return releaseRepo.save(release); + } + + @Test + @Transactional + public void testSetOperationWork() throws Exception { + tester.setUser(getUser()); + Sample[] samples = makeSamples(4); + Work[] works = makeWorks(3); + LabwareType lt = entityCreator.createLabwareType("lt", 1, 5); + Labware lw = entityCreator.createLabware("STAN-1", lt, samples); + List slots = lw.getSlots(); + Operation op0 = makeOp(slots.get(0), samples[0], slots.get(1), samples[1]); + Operation op1 = makeOp(slots.get(1), samples[1], slots.get(2), samples[2]); + Operation op2 = makeOp(slots.get(0), samples[0], slots.get(3), samples[3]); + works[0].setOperationIds(hashSetOf(op0.getId(), op2.getId())); + works[1].setOperationIds(hashSetOf(op1.getId())); + Release release = makeRelease(lw, slots.get(1), samples[1], slots.get(3), samples[3]); + works[1].setReleaseIds(hashSetOf(release.getId())); + works[0].setSampleSlotIds(hashSetOf(new SampleSlotId(samples[0].getId(), slots.get(0).getId()), + new SampleSlotId(samples[1].getId(), slots.get(1).getId()), + new SampleSlotId(samples[3].getId(), slots.get(3).getId()))); + + works[1].setSampleSlotIds(hashSetOf(new SampleSlotId(samples[1].getId(), slots.get(1).getId()), + new SampleSlotId(samples[2].getId(), slots.get(2).getId()), + new SampleSlotId(samples[3].getId(), slots.get(3).getId()))); + + workRepo.saveAll(Arrays.asList(works)); + + String mutation = tester.readGraphQL("setoperationwork.graphql") + .replace("[WORK]", works[2].getWorkNumber()) + .replace("999", op0.getId()+","+op1.getId()); + Object response = tester.post(mutation); + + List> responseOps = chainGet(response, "data", "setOperationWork"); + assertThat(responseOps).hasSize(2); + assertEquals(op0.getId(), responseOps.get(0).get("id")); + assertEquals(op1.getId(), responseOps.get(1).get("id")); + + entityManager.flush(); + Arrays.stream(works).forEach(entityManager::refresh); + + assertThat(works[2].getOperationIds()).containsExactlyInAnyOrder(op0.getId(), op1.getId()); + assertThat(works[2].getSampleSlotIds()).containsExactlyInAnyOrder( + new SampleSlotId(samples[0].getId(), slots.get(0).getId()), + new SampleSlotId(samples[1].getId(), slots.get(1).getId()), + new SampleSlotId(samples[2].getId(), slots.get(2).getId()) + ); + + assertThat(works[0].getOperationIds()).containsExactlyInAnyOrder(op2.getId()); + assertThat(works[1].getOperationIds()).isEmpty(); + assertThat(works[1].getReleaseIds()).containsExactly(release.getId()); + assertThat(works[0].getSampleSlotIds()).containsExactlyInAnyOrder( + new SampleSlotId(samples[0].getId(), slots.get(0).getId()), + new SampleSlotId(samples[3].getId(), slots.get(3).getId()) + ); + assertThat(works[1].getSampleSlotIds()).containsExactlyInAnyOrder( + new SampleSlotId(samples[1].getId(), slots.get(1).getId()), + new SampleSlotId(samples[3].getId(), slots.get(3).getId()) + ); + + // Adding work 2 to operations 0 -- slotsamples 0,0, 1,1 -- and 1 -- slotsamples 1,1, 2,2 + // op 0 is already linked to work 0 (0-0 and 1-1) + // op 1 is already linked to work 1 (1-1 and 2-2) + // work 0 0-0 is covered by op 2 (0-0, 3-3) + // work 1 1-1 is covered by release 0 (1-1, 3-3) + // So after the update, work 2 should be linked to ops 1 and 2 with their ss, + // work 0 should only be linked to op 2, ss 0-0,3-3 + // work 1 should only be linked to release 0, ss 1-1,3-3 + } +} diff --git a/src/test/java/uk/ac/sanger/sccp/stan/repo/TestOperationRepo.java b/src/test/java/uk/ac/sanger/sccp/stan/repo/TestOperationRepo.java index c650bdf7..be80d666 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/repo/TestOperationRepo.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/repo/TestOperationRepo.java @@ -10,8 +10,7 @@ import javax.persistence.EntityManager; import javax.transaction.Transactional; -import java.util.Arrays; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -107,6 +106,26 @@ public void testFindAllByOperationTypeAndDestinationSlotIdIn() { assertThat(foundOps).containsExactlyInAnyOrder(ops[0], ops[1]); } + @Test + @Transactional + public void testFindOpSlotSampleIds() { + setUpOps(); + int op0id = ops[0].getId(); + int op1id = ops[1].getId(); + int[] slotIds = Arrays.stream(lws).mapToInt(lw -> lw.getFirstSlot().getId()).toArray(); + int[] sampleIds = Arrays.stream(samples).mapToInt(Sample::getId).toArray(); + List opIds = List.of(op0id, op1id); + Map> opSsids = opRepo.findOpSlotSampleIds(opIds); + assertThat(opSsids.keySet()).containsExactlyInAnyOrderElementsOf(opIds); + assertThat(opSsids.get(op0id)).containsExactly( + new SlotIdSampleId(slotIds[0], sampleIds[0]) + ); + assertThat(opSsids.get(op1id)).containsExactlyInAnyOrder( + new SlotIdSampleId(slotIds[1], sampleIds[1]), + new SlotIdSampleId(slotIds[2], sampleIds[2]) + ); + } + private Operation makeOp(OperationType opType, Labware... labware) { if (user==null) { user = entityCreator.createUser("user1"); diff --git a/src/test/java/uk/ac/sanger/sccp/stan/repo/TestReleaseRepo.java b/src/test/java/uk/ac/sanger/sccp/stan/repo/TestReleaseRepo.java index 56345ee0..ebc8feb6 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/repo/TestReleaseRepo.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/repo/TestReleaseRepo.java @@ -10,7 +10,7 @@ import javax.persistence.EntityManager; import javax.transaction.Transactional; -import java.util.List; +import java.util.*; import java.util.stream.IntStream; import static java.util.stream.Collectors.toList; @@ -123,4 +123,22 @@ public void testFindAllIdIn() { assertThat(releaseRepo.findAllByIdIn(List.of(id0+id1))).isEmpty(); } + @Test + @Transactional + public void testFindReleaseSlotSampleIds() { + Release[] releases = createReleases(); + int r0id = releases[0].getId(); + int r1id = releases[1].getId(); + List rids = List.of(r0id, r1id); + Map> rssids = releaseRepo.findReleaseSlotSampleIds(rids); + assertThat(rssids.keySet()).containsExactlyInAnyOrderElementsOf(rids); + Slot[] slots = Arrays.stream(releases).map(r -> r.getLabware().getFirstSlot()).toArray(Slot[]::new); + assertThat(rssids.get(r0id)).containsExactly( + new SlotIdSampleId(slots[0], slots[0].getSamples().get(0)) + ); + assertThat(rssids.get(r1id)).containsExactly( + new SlotIdSampleId(slots[1], slots[1].getSamples().get(0)) + ); + } + } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java new file mode 100644 index 00000000..6e82698a --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java @@ -0,0 +1,247 @@ +package uk.ac.sanger.sccp.stan.service.workchange; + +import org.junit.jupiter.api.*; +import org.mockito.*; +import uk.ac.sanger.sccp.stan.EntityFactory; +import uk.ac.sanger.sccp.stan.model.*; +import uk.ac.sanger.sccp.stan.repo.*; +import uk.ac.sanger.sccp.stan.request.OpWorkRequest; +import uk.ac.sanger.sccp.stan.service.ValidationException; +import uk.ac.sanger.sccp.stan.service.work.WorkService; +import uk.ac.sanger.sccp.utils.UCMap; + +import java.util.*; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static uk.ac.sanger.sccp.stan.Matchers.assertValidationException; +import static uk.ac.sanger.sccp.stan.Matchers.sameElements; +import static uk.ac.sanger.sccp.utils.BasicUtils.inMap; + +/** Test {@link WorkChangeServiceImp} */ +class TestWorkChangeService { + + @Mock + WorkChangeValidationService mockValidationService; + @Mock + WorkService mockWorkService; + @Mock + WorkRepo mockWorkRepo; + @Mock + OperationRepo mockOpRepo; + @Mock + ReleaseRepo mockReleaseRepo; + + @InjectMocks + WorkChangeServiceImp service; + + private AutoCloseable mocking; + + @BeforeEach + void setup() { + mocking = MockitoAnnotations.openMocks(this); + service = spy(service); + } + + @AfterEach + void cleanup() throws Exception { + mocking.close(); + } + + private static Operation opWithId(int id) { + Operation op = new Operation(); + op.setId(id); + return op; + } + + @Test + void testPerform_invalid() { + List problems = List.of("Bad"); + doThrow(new ValidationException(problems)).when(mockValidationService).validate(any()); + OpWorkRequest request = new OpWorkRequest("SGP1", List.of(1)); + assertValidationException(() -> service.perform(request), problems); + verify(service, never()).execute(any(), any()); + } + + @Test + void testPerform_valid() { + Work work = EntityFactory.makeWork("SGP1"); + List ops = List.of(opWithId(1), opWithId(2)); + WorkChangeData data = new WorkChangeData(work, ops); + when(mockValidationService.validate(any())).thenReturn(data); + OpWorkRequest request = new OpWorkRequest("SGP1", List.of(1, 2)); + doReturn(ops).when(service).execute(any(), any()); + + assertSame(ops, service.perform(request)); + verify(mockValidationService).validate(request); + verify(service).execute(work, ops); + } + + @Test + void testExecute() { + Work work = EntityFactory.makeWork("SGP1"); + List ops = List.of(opWithId(1), opWithId(2)); + doNothing().when(service).clearOutPriorWorks(any()); + service.execute(work, ops); + InOrder inOrder = inOrder(service, mockWorkService); + inOrder.verify(service).clearOutPriorWorks(ops); + inOrder.verify(mockWorkService).link(work, ops); + } + + @Test + void testLoadOpIdWorks() { + Work[] works = IntStream.rangeClosed(1,3).mapToObj(i -> EntityFactory.makeWork("SGP"+i)).toArray(Work[]::new); + List ops = List.of(opWithId(1), opWithId(2)); + Map> opIdWorkNumbers = Map.of( + 1, Set.of("SGP1", "SGP2"), + 2, Set.of("SGP2", "SGP3") + ); + UCMap workMap = UCMap.from(Work::getWorkNumber, works); + + when(mockWorkRepo.findWorkNumbersForOpIds(any())).thenReturn(opIdWorkNumbers); + when(mockWorkRepo.getMapByWorkNumberIn(any())).thenReturn(workMap); + + Map> expected = Map.of( + 1, Set.of(works[0], works[1]), + 2, Set.of(works[1], works[2]) + ); + + assertEquals(expected, service.loadOpIdWorks(ops)); + verify(mockWorkRepo).findWorkNumbersForOpIds(List.of(1,2)); + verify(mockWorkRepo).getMapByWorkNumberIn(Set.of("SGP1", "SGP2", "SGP3")); + } + + @Test + void testWorkExtantSlotSampleIds() { + List works = IntStream.rangeClosed(1,4).mapToObj(i -> EntityFactory.makeWork("SGP"+i)).toList(); + works.get(0).setOperationIds(Set.of(1,2,4)); + works.get(1).setOperationIds(Set.of(2,3,4,5)); + works.get(2).setOperationIds(Set.of(3)); + works.get(2).setReleaseIds(Set.of(11,12)); + int[] workIds = works.stream().mapToInt(Work::getId).toArray(); + Map> opSsids = Map.of( + 1, Set.of(new SlotIdSampleId(20,30), + new SlotIdSampleId(21,31)), + 2, Set.of(new SlotIdSampleId(20,31), + new SlotIdSampleId(21,31)), + 3, Set.of(new SlotIdSampleId(22,32)) + ); + Map> releaseSsids = Map.of( + 11, Set.of(new SlotIdSampleId(20,30), + new SlotIdSampleId(20,33)), + 12, Set.of(new SlotIdSampleId(25,35)) + ); + when(mockOpRepo.findOpSlotSampleIds(any())).thenReturn(opSsids); + when(mockReleaseRepo.findReleaseSlotSampleIds(any())).thenReturn(releaseSsids); + + Map> result = service.workExtantSlotSampleIds(works, Set.of(4,5,6)); + + verify(mockOpRepo).findOpSlotSampleIds(Set.of(1,2,3)); + verify(mockReleaseRepo).findReleaseSlotSampleIds(Set.of(11,12)); + + assertThat(result).containsKeys(workIds[0], workIds[1], workIds[2]); + assertThat(result.get(workIds[0])).containsExactlyInAnyOrder( + new SlotIdSampleId(20,30), + new SlotIdSampleId(21,31), + new SlotIdSampleId(20,31) + ); + assertThat(result.get(workIds[1])).containsExactlyInAnyOrder( + new SlotIdSampleId(20,31), + new SlotIdSampleId(21,31), + new SlotIdSampleId(22,32) + ); + assertThat(result.get(workIds[2])).containsExactlyInAnyOrder( + new SlotIdSampleId(22,32), + new SlotIdSampleId(20,30), + new SlotIdSampleId(20,33), + new SlotIdSampleId(25,35) + ); + assertThat(result.get(workIds[3])).isNullOrEmpty(); + } + + @Test + void testGetOpSlotSampleIds() { + LabwareType lt = EntityFactory.makeLabwareType(1,3); + Sample[] sams = EntityFactory.makeSamples(3); + Labware lw = EntityFactory.makeLabware(lt, sams); + List slots = lw.getSlots(); + List actions = List.of( + new Action(1, 1, slots.get(0), slots.get(0), sams[0], sams[0]), + new Action(2, 1, slots.get(0), slots.get(1), sams[1], sams[0]), + new Action(3, 1, slots.get(2), slots.get(2), sams[2], sams[2]), + new Action(4, 1, slots.get(2), slots.get(2), sams[1], sams[2]) + ); + Operation op = opWithId(1); + op.setActions(actions); + Set result = service.getOpSlotSampleIds(op); + assertThat(result).containsExactlyInAnyOrder( + new SlotIdSampleId(slots.get(0), sams[0]), + new SlotIdSampleId(slots.get(1), sams[1]), + new SlotIdSampleId(slots.get(2), sams[2]), + new SlotIdSampleId(slots.get(2), sams[1]) + ); + } + + @Test + void testFindSampleSlotIdsToRemove() { + Work[] works = IntStream.of(1,2).mapToObj(i -> EntityFactory.makeWork("SGP"+i)).toArray(Work[]::new); + Map workIdMap = Arrays.stream(works).collect(inMap(Work::getId)); + List ops = IntStream.of(1,2).mapToObj(TestWorkChangeService::opWithId).toList(); + Map> opIdWorks = Map.of(1, Set.of(works[0]), 2, Set.of(works[1])); + Map> extantWorkSsids = Map.of( + works[0].getId(), Set.of(new SlotIdSampleId(10,20), new SlotIdSampleId(11,21)), + works[1].getId(), Set.of(new SlotIdSampleId(12,22), new SlotIdSampleId(13,23)) + ); + doReturn(extantWorkSsids).when(service).workExtantSlotSampleIds(any(), any()); + Set op1ssids = Set.of(new SlotIdSampleId(10,20), new SlotIdSampleId(13,23), + new SlotIdSampleId(14,24), new SlotIdSampleId(15,25)); + Set op2ssids = Set.of(new SlotIdSampleId(11,21), new SlotIdSampleId(12,22)); + doReturn(op1ssids).when(service).getOpSlotSampleIds(ops.get(0)); + doReturn(op2ssids).when(service).getOpSlotSampleIds(ops.get(1)); + + Map> toRemove = service.findSampleSlotIdsToRemove(ops, workIdMap, opIdWorks); + verify(service).workExtantSlotSampleIds(workIdMap.values(), opIdWorks.keySet()); + + assertThat(toRemove).containsKeys(works[0].getId(), works[1].getId()); + assertThat(toRemove.get(works[0].getId())).containsExactlyInAnyOrder( + new Work.SampleSlotId(23,13), + new Work.SampleSlotId(24,14), + new Work.SampleSlotId(25,15) + ); + assertThat(toRemove.get(works[1].getId())).containsExactly(new Work.SampleSlotId(21,11)); + } + + @Test + void testClearOutPriorWorks() { + List ops = IntStream.of(1,2).mapToObj(TestWorkChangeService::opWithId).toList(); + Work[] works = IntStream.of(1,2).mapToObj(i -> EntityFactory.makeWork("SGP"+i)).toArray(Work[]::new); + Map> opIdWorks = Map.of(1, Set.of(works[0]), 2, Set.of(works[1])); + Map workIdMap = Map.of(works[0].getId(), works[0], works[1].getId(), works[1]); + Map> toRemove = Map.of( + works[0].getId(), Set.of(new Work.SampleSlotId(20,10), new Work.SampleSlotId(21,11)), + works[1].getId(), Set.of(new Work.SampleSlotId(22,12), new Work.SampleSlotId(23,13)) + ); + + works[0].setSampleSlotIds(new HashSet<>(Set.of(new Work.SampleSlotId(20,10), new Work.SampleSlotId(22,12)))); + works[1].setSampleSlotIds(new HashSet<>(Set.of(new Work.SampleSlotId(23,13), new Work.SampleSlotId(24,14)))); + + works[0].setOperationIds(new HashSet<>(Set.of(1,2,3,4))); + works[1].setOperationIds(new HashSet<>(Set.of(2,5))); + + doReturn(opIdWorks).when(service).loadOpIdWorks(ops); + doReturn(toRemove).when(service).findSampleSlotIdsToRemove(ops, workIdMap, opIdWorks); + + service.clearOutPriorWorks(ops); + verify(mockWorkRepo).saveAll(sameElements(workIdMap.values(), true)); + + assertThat(works[0].getSampleSlotIds()).containsExactlyInAnyOrder(new Work.SampleSlotId(22,12)); + assertThat(works[1].getSampleSlotIds()).containsExactlyInAnyOrder(new Work.SampleSlotId(24,14)); + + assertThat(works[0].getOperationIds()).containsExactlyInAnyOrder(3,4); + assertThat(works[1].getOperationIds()).containsExactlyInAnyOrder(5); + } +} \ No newline at end of file diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeValidationService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeValidationService.java new file mode 100644 index 00000000..caddeac9 --- /dev/null +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeValidationService.java @@ -0,0 +1,192 @@ +package uk.ac.sanger.sccp.stan.service.workchange; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.*; +import uk.ac.sanger.sccp.stan.EntityFactory; +import uk.ac.sanger.sccp.stan.model.Operation; +import uk.ac.sanger.sccp.stan.model.Work; +import uk.ac.sanger.sccp.stan.repo.OperationRepo; +import uk.ac.sanger.sccp.stan.repo.WorkRepo; +import uk.ac.sanger.sccp.stan.request.OpWorkRequest; + +import java.util.*; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.*; +import static uk.ac.sanger.sccp.stan.Matchers.*; +import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; + +/** Tests {@link WorkChangeValidationServiceImp} */ +class TestWorkChangeValidationService { + @Mock + WorkRepo mockWorkRepo; + @Mock + OperationRepo mockOpRepo; + @InjectMocks + WorkChangeValidationServiceImp service; + + private AutoCloseable mocking; + + @BeforeEach + void setup() { + mocking = MockitoAnnotations.openMocks(this); + service = spy(service); + } + + @AfterEach + void cleanup() throws Exception { + mocking.close(); + } + + @Test + void testValidate_null() { + assertValidationException(() -> service.validate(null), + List.of("No request supplied.")); + verify(service, never()).loadWork(any(), any()); + } + + @Test + void testValidate_noOpIds() { + OpWorkRequest request = new OpWorkRequest("SGP1", null); + doReturn(null).when(service).loadWork(any(), any()); + assertValidationException(() -> service.validate(request), + List.of("No operations specified.")); + verify(service, never()).loadOps(any(), any(), any()); + } + + @Test + void testValidate_problems() { + OpWorkRequest request = new OpWorkRequest("SGP1", List.of(1,2)); + mayAddProblem("Bad work number.", null).when(service).loadWork(any(), any()); + mayAddProblem("Bad op ids.", List.of()).when(service).loadOps(any(), any(), any()); + assertValidationException(() -> service.validate(request), + List.of("Bad work number.", "Bad op ids.")); + verify(service).loadWork(any(), eq("SGP1")); + verify(service).loadOps(any(), any(), same(request.getOpIds())); + } + + private static Operation makeOp(int id) { + Operation op = new Operation(); + op.setId(id); + return op; + } + + @Test + void testValidate_good() { + OpWorkRequest request = new OpWorkRequest("SGP1", List.of(1,2)); + Work work = EntityFactory.makeWork("SGP1"); + List ops = IntStream.of(1,2) + .mapToObj(TestWorkChangeValidationService::makeOp).toList(); + doReturn(work).when(service).loadWork(any(), any()); + doReturn(ops).when(service).loadOps(any(), any(), any()); + assertEquals(new WorkChangeData(work, ops), service.validate(request)); + verify(service).loadWork(any(), eq("SGP1")); + verify(service).loadOps(any(), same(work), same(request.getOpIds())); + } + + @ParameterizedTest + @ValueSource(strings={"[null]", "", "SGP1", "SGPX"}) + void testLoadWork(String workNumber) { + if (workNumber.equalsIgnoreCase("[null]")) { + workNumber = null; + } + Work work = null; + if (workNumber != null && workNumber.equalsIgnoreCase("SGP1")) { + work = EntityFactory.makeWork(workNumber); + } + String expectedProblem; + int vtimes = 1; + if (nullOrEmpty(workNumber)) { + expectedProblem = "No work number specified."; + vtimes = 0; + } else if (work==null) { + doReturn(Optional.empty()).when(mockWorkRepo).findByWorkNumber(workNumber); + expectedProblem = String.format("No work found with work number: \"%s\"", workNumber); + } else { + doReturn(Optional.of(work)).when(mockWorkRepo).findByWorkNumber(workNumber); + expectedProblem = null; + } + List problems = new ArrayList<>(expectedProblem==null ? 0 : 1); + assertSame(work, service.loadWork(problems, workNumber)); + assertProblem(problems, expectedProblem); + if (vtimes==0) { + verifyNoInteractions(mockWorkRepo); + } else { + verify(mockWorkRepo).findByWorkNumber(workNumber); + } + } + + @Test + void testLoadOps_duplicates() { + final String dupeError = "Repeated operation ID: 1"; + List ops = List.of(makeOp(1), makeOp(2)); + when(mockOpRepo.findAllById(any())).thenReturn(ops); + Work work = EntityFactory.makeWork("SGP1"); + work.setOperationIds(Set.of(3,4,5)); + List problems = new ArrayList<>(1); + assertThat(service.loadOps(problems, work, List.of(1,1,2,3))) + .containsExactly(ops.get(0), ops.get(1)); + assertProblem(problems, dupeError); + verify(service).dedupe(any(), eq(List.of(1,1,2,3)), eq("operation ID")); + verify(mockOpRepo).findAllById(List.of(1,2)); + } + + @Test + void testLoadOps_unknown() { + List opIds = List.of(1,2,3,4); + List ops = List.of(makeOp(1), makeOp(2)); + when(mockOpRepo.findAllById(any())).thenReturn(ops.reversed()); + List problems = new ArrayList<>(1); + assertThat(service.loadOps(problems, null, opIds)).containsExactlyElementsOf(ops); + assertProblem(problems, "Unknown operation IDs: [3, 4]"); + verify(service).dedupe(any(), eq(opIds), eq("operation ID")); + verify(mockOpRepo).findAllById(opIds); + } + + @Test + void testLoadOps_allLinked() { + List opIds = List.of(1,2); + Work work = EntityFactory.makeWork("SGP1"); + work.setOperationIds(Set.of(1,2,3)); + List problems = new ArrayList<>(1); + assertThat(service.loadOps(problems, work, opIds)).isEmpty(); + assertProblem(problems, "Specified operations are already linked to work SGP1."); + verify(service).dedupe(any(), eq(opIds), eq("operation ID")); + verifyNoInteractions(mockOpRepo); + } + + @Test + void testLoadOps_valid() { + List opIds = List.of(1,2,3); + Work work = EntityFactory.makeWork("SGP1"); + work.setOperationIds(Set.of(3,4)); + List ops = List.of(makeOp(1), makeOp(2)); + when(mockOpRepo.findAllById(any())).thenReturn(ops.reversed()); + List problems = new ArrayList<>(0); + assertThat(service.loadOps(problems, work, opIds)).containsExactlyElementsOf(ops); + assertThat(problems).isEmpty(); + verify(service).dedupe(any(), eq(opIds), eq("operation ID")); + verify(mockOpRepo).findAllById(List.of(1,2)); + } + + @Test + void testDedupe() { + List problems = new ArrayList<>(); + assertThat(service.dedupe(problems, List.of("Alpha", "Beta", "Gamma", "Delta"), "string")) + .containsExactly("Alpha", "Beta", "Gamma", "Delta"); + assertThat(problems).isEmpty(); + problems = new ArrayList<>(2); + assertThat(service.dedupe(problems, List.of("Alpha", "Beta", "Gamma", "Beta", "Delta", "Gamma"), "string")) + .containsExactly("Alpha", "Beta", "Gamma", "Delta"); + assertThat(problems).containsExactly("Repeated string: Beta", "Repeated string: Gamma"); + problems = new ArrayList<>(1); + assertThat(service.dedupe(problems, Arrays.asList("Alpha", "Beta", null, "Gamma"), "string")) + .containsExactly("Alpha", "Beta", "Gamma"); + assertThat(problems).containsExactly("Missing string."); + } +} \ No newline at end of file diff --git a/src/test/resources/graphql/setoperationwork.graphql b/src/test/resources/graphql/setoperationwork.graphql new file mode 100644 index 00000000..07d014aa --- /dev/null +++ b/src/test/resources/graphql/setoperationwork.graphql @@ -0,0 +1,8 @@ +mutation { + setOperationWork(request: { + workNumber: "[WORK]" + opIds: [999] + }) { + id + } +} \ No newline at end of file From 281cc826abfc486396ef896b12f4bcb31bfc41ff Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 17 Apr 2025 17:26:05 +0100 Subject: [PATCH 18/37] x1305 allow existing ops to be linked to inactive works and tidy up TestSetOperationWorkMutation integ test --- .../sccp/stan/service/work/WorkService.java | 14 +++++++- .../stan/service/work/WorkServiceImp.java | 6 ++-- .../workchange/WorkChangeServiceImp.java | 2 +- .../TestSetOperationWorkMutation.java | 34 ++++++++----------- .../workchange/TestWorkChangeService.java | 2 +- 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/work/WorkService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/work/WorkService.java index d2c0157b..64249a47 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/work/WorkService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/work/WorkService.java @@ -118,7 +118,19 @@ Work createWork(User user, String prefix, String workTypeName, String workReques * @return the updated and saved work * @exception IllegalArgumentException if the work is not active */ - Work link(Work work, Collection operations); + default Work link(Work work, Collection operations) { + return link(work, operations, false); + } + + /** + * Updates the existing work linking it to the given operations and samples in slots in the ops' actions + * @param work the work + * @param operations the operations to link + * @param evenIfUnusable pass true to let inactive work be linked + * @return the updated and saved work + * @exception IllegalArgumentException if the work is not active and evenIfUnusable if false + */ + Work link(Work work, Collection operations, boolean evenIfUnusable); /** * Links the indicated works to the indicated ops. diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/work/WorkServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/work/WorkServiceImp.java index 5c6eff3b..04a6abbf 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/work/WorkServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/work/WorkServiceImp.java @@ -257,15 +257,15 @@ public Work updateWorkDnapStudy(User user, String workNumber, Integer ssStudyId) @Override public Work link(String workNumber, Collection operations) { Work work = workRepo.getByWorkNumber(workNumber); - return link(work, operations); + return link(work, operations, false); } @Override - public Work link(Work work, Collection operations) { + public Work link(Work work, Collection operations, boolean evenIfUnusable) { if (operations.isEmpty()) { return work; } - if (!work.isUsable()) { + if (!evenIfUnusable && !work.isUsable()) { throw new IllegalArgumentException(work.getWorkNumber()+" cannot be used because it is "+ work.getStatus()+"."); } Set opIds = work.getOperationIds(); diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java index 24b42915..d1c733db 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java @@ -55,7 +55,7 @@ public List perform(OpWorkRequest request) throws ValidationException */ public List execute(Work work, List ops) { clearOutPriorWorks(ops); - workService.link(work, ops); + workService.link(work, ops, true); return ops; } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java index caa9a6f5..c28a1347 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java @@ -136,6 +136,15 @@ private Release makeRelease(Labware lw, Object... args) { return releaseRepo.save(release); } + private Set ssIds(Sample[] samples, Labware lw, int... indexes) { + Set ssIds = new HashSet<>(indexes.length); + final List slots = lw.getSlots(); + for (int index : indexes) { + ssIds.add(new SampleSlotId(samples[index].getId(), slots.get(index).getId())); + } + return ssIds; + } + @Test @Transactional public void testSetOperationWork() throws Exception { @@ -152,13 +161,10 @@ public void testSetOperationWork() throws Exception { works[1].setOperationIds(hashSetOf(op1.getId())); Release release = makeRelease(lw, slots.get(1), samples[1], slots.get(3), samples[3]); works[1].setReleaseIds(hashSetOf(release.getId())); - works[0].setSampleSlotIds(hashSetOf(new SampleSlotId(samples[0].getId(), slots.get(0).getId()), - new SampleSlotId(samples[1].getId(), slots.get(1).getId()), - new SampleSlotId(samples[3].getId(), slots.get(3).getId()))); - works[1].setSampleSlotIds(hashSetOf(new SampleSlotId(samples[1].getId(), slots.get(1).getId()), - new SampleSlotId(samples[2].getId(), slots.get(2).getId()), - new SampleSlotId(samples[3].getId(), slots.get(3).getId()))); + works[0].setSampleSlotIds(ssIds(samples, lw, 0, 1, 3)); + + works[1].setSampleSlotIds(ssIds(samples, lw, 1, 2, 3)); workRepo.saveAll(Arrays.asList(works)); @@ -176,23 +182,13 @@ public void testSetOperationWork() throws Exception { Arrays.stream(works).forEach(entityManager::refresh); assertThat(works[2].getOperationIds()).containsExactlyInAnyOrder(op0.getId(), op1.getId()); - assertThat(works[2].getSampleSlotIds()).containsExactlyInAnyOrder( - new SampleSlotId(samples[0].getId(), slots.get(0).getId()), - new SampleSlotId(samples[1].getId(), slots.get(1).getId()), - new SampleSlotId(samples[2].getId(), slots.get(2).getId()) - ); + assertThat(works[2].getSampleSlotIds()).containsExactlyInAnyOrderElementsOf(ssIds(samples, lw, 0, 1, 2)); assertThat(works[0].getOperationIds()).containsExactlyInAnyOrder(op2.getId()); assertThat(works[1].getOperationIds()).isEmpty(); assertThat(works[1].getReleaseIds()).containsExactly(release.getId()); - assertThat(works[0].getSampleSlotIds()).containsExactlyInAnyOrder( - new SampleSlotId(samples[0].getId(), slots.get(0).getId()), - new SampleSlotId(samples[3].getId(), slots.get(3).getId()) - ); - assertThat(works[1].getSampleSlotIds()).containsExactlyInAnyOrder( - new SampleSlotId(samples[1].getId(), slots.get(1).getId()), - new SampleSlotId(samples[3].getId(), slots.get(3).getId()) - ); + assertThat(works[0].getSampleSlotIds()).containsExactlyInAnyOrderElementsOf(ssIds(samples, lw, 0, 3)); + assertThat(works[1].getSampleSlotIds()).containsExactlyInAnyOrderElementsOf(ssIds(samples, lw, 1, 3)); // Adding work 2 to operations 0 -- slotsamples 0,0, 1,1 -- and 1 -- slotsamples 1,1, 2,2 // op 0 is already linked to work 0 (0-0 and 1-1) diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java index 6e82698a..68480f9f 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java @@ -89,7 +89,7 @@ void testExecute() { service.execute(work, ops); InOrder inOrder = inOrder(service, mockWorkService); inOrder.verify(service).clearOutPriorWorks(ops); - inOrder.verify(mockWorkService).link(work, ops); + inOrder.verify(mockWorkService).link(work, ops, true); } @Test From 545d54c1f6f2e8f7bed956b64961a24927fab652 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:42:04 +0100 Subject: [PATCH 19/37] x1305 add WorkChange record as audit trail for work change requests --- .../ac/sanger/sccp/stan/GraphQLMutation.java | 2 +- .../ac/sanger/sccp/stan/model/WorkChange.java | 59 ++++++++++ .../sccp/stan/model/WorkChangeLink.java | 106 ++++++++++++++++++ .../sccp/stan/repo/WorkChangeLinkRepo.java | 7 ++ .../sanger/sccp/stan/repo/WorkChangeRepo.java | 7 ++ .../service/workchange/WorkChangeService.java | 3 +- .../workchange/WorkChangeServiceImp.java | 47 ++++++-- .../resources/db/changelog/changelog-3.06.xml | 52 +++++++++ .../db/changelog/changelog-master.xml | 1 + .../TestSetOperationWorkMutation.java | 31 ++++- .../workchange/TestWorkChangeService.java | 86 ++++++++++++-- 11 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/model/WorkChange.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/model/WorkChangeLink.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/repo/WorkChangeLinkRepo.java create mode 100644 src/main/java/uk/ac/sanger/sccp/stan/repo/WorkChangeRepo.java create mode 100644 src/main/resources/db/changelog/changelog-3.06.xml diff --git a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java index 0af335b2..a2346819 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/GraphQLMutation.java @@ -941,7 +941,7 @@ public DataFetcher> setOperationWork() { User user = checkUser(dfe, User.Role.normal); OpWorkRequest request = arg(dfe, "request", OpWorkRequest.class); logRequest("setOperationWork", user, request); - return workChangeService.perform(request); + return workChangeService.perform(user, request); }; } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/WorkChange.java b/src/main/java/uk/ac/sanger/sccp/stan/model/WorkChange.java new file mode 100644 index 00000000..484bb3c2 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/WorkChange.java @@ -0,0 +1,59 @@ +package uk.ac.sanger.sccp.stan.model; + +import javax.persistence.*; +import java.util.Objects; + +/** + * A record of operations' work being changed. + * @author dr6 + */ +@Entity +public class WorkChange implements HasIntId { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + private Integer userId; + + public WorkChange() {} + + public WorkChange(Integer id, Integer userId) { + this.id = id; + this.userId = userId; + } + + @Override + public Integer getId() { + return this.id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getUserId() { + return this.userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WorkChange that = (WorkChange) o; + return (Objects.equals(this.id, that.id) + && Objects.equals(this.userId, that.userId)); + } + + @Override + public int hashCode() { + return Objects.hash(id, userId); + } + + @Override + public String toString() { + return String.format("WorkChange(id=%s, userId=%s)", id, userId); + } +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/WorkChangeLink.java b/src/main/java/uk/ac/sanger/sccp/stan/model/WorkChangeLink.java new file mode 100644 index 00000000..b7bb123c --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/WorkChangeLink.java @@ -0,0 +1,106 @@ +package uk.ac.sanger.sccp.stan.model; + +import uk.ac.sanger.sccp.utils.BasicUtils; + +import javax.persistence.*; +import java.util.Objects; + +/** + * A link between work and operation made or removed in a work change. + * @author dr6 + */ +@Entity +public class WorkChangeLink implements HasIntId { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + private Integer workChangeId; + private Integer operationId; + private Integer workId; + private boolean link; + + public WorkChangeLink() { + } + + public WorkChangeLink(Integer id, Integer workChangeId, Integer operationId, Integer workId, boolean link) { + this.id = id; + this.workChangeId = workChangeId; + this.operationId = operationId; + this.workId = workId; + this.link = link; + } + + public WorkChangeLink(Integer workChangeId, Integer operationId, Integer workId, boolean link) { + this(null, workChangeId, operationId, workId, link); + } + + @Override + public Integer getId() { + return this.id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getWorkChangeId() { + return this.workChangeId; + } + + public void setWorkChangeId(Integer workChangeId) { + this.workChangeId = workChangeId; + } + + public Integer getOperationId() { + return this.operationId; + } + + public void setOperationId(Integer operationId) { + this.operationId = operationId; + } + + public Integer getWorkId() { + return this.workId; + } + + public void setWorkId(Integer workId) { + this.workId = workId; + } + + /** True if a link was added; false if a link was removed */ + public boolean isLink() { + return this.link; + } + + public void setLink(boolean link) { + this.link = link; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WorkChangeLink that = (WorkChangeLink) o; + return (this.link == that.link + && Objects.equals(this.id, that.id) + && Objects.equals(this.workChangeId, that.workChangeId) + && Objects.equals(this.operationId, that.operationId) + && Objects.equals(this.workId, that.workId)); + } + + @Override + public int hashCode() { + return Objects.hash(id, workChangeId, operationId, workId, link); + } + + @Override + public String toString() { + return BasicUtils.describe(this) + .add("id", id) + .add("workChangeId", workChangeId) + .add("operationId", operationId) + .add("workId", workId) + .add("link", link) + .toString(); + } +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkChangeLinkRepo.java b/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkChangeLinkRepo.java new file mode 100644 index 00000000..83d04793 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkChangeLinkRepo.java @@ -0,0 +1,7 @@ +package uk.ac.sanger.sccp.stan.repo; + +import org.springframework.data.repository.CrudRepository; +import uk.ac.sanger.sccp.stan.model.WorkChangeLink; + +public interface WorkChangeLinkRepo extends CrudRepository { +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkChangeRepo.java b/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkChangeRepo.java new file mode 100644 index 00000000..5dbe0fe7 --- /dev/null +++ b/src/main/java/uk/ac/sanger/sccp/stan/repo/WorkChangeRepo.java @@ -0,0 +1,7 @@ +package uk.ac.sanger.sccp.stan.repo; + +import org.springframework.data.repository.CrudRepository; +import uk.ac.sanger.sccp.stan.model.WorkChange; + +public interface WorkChangeRepo extends CrudRepository { +} diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeService.java index da688795..7c9504e6 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeService.java @@ -1,6 +1,7 @@ package uk.ac.sanger.sccp.stan.service.workchange; import uk.ac.sanger.sccp.stan.model.Operation; +import uk.ac.sanger.sccp.stan.model.User; import uk.ac.sanger.sccp.stan.request.OpWorkRequest; import uk.ac.sanger.sccp.stan.service.ValidationException; @@ -9,5 +10,5 @@ /** Service for changing work linked to prior events */ public interface WorkChangeService { /** Validate and perform the request. */ - List perform(OpWorkRequest request) throws ValidationException; + List perform(User user, OpWorkRequest request) throws ValidationException; } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java index d1c733db..2305da66 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/workchange/WorkChangeServiceImp.java @@ -30,32 +30,39 @@ public class WorkChangeServiceImp implements WorkChangeService { private final WorkRepo workRepo; private final OperationRepo opRepo; private final ReleaseRepo releaseRepo; + private final WorkChangeRepo workChangeRepo; + private final WorkChangeLinkRepo linkRepo; @Autowired public WorkChangeServiceImp(WorkChangeValidationService validationService, WorkService workService, - WorkRepo workRepo, OperationRepo opRepo, ReleaseRepo releaseRepo) { + WorkRepo workRepo, OperationRepo opRepo, ReleaseRepo releaseRepo, + WorkChangeRepo workChangeRepo, WorkChangeLinkRepo linkRepo) { this.validationService = validationService; this.workService = workService; this.workRepo = workRepo; this.opRepo = opRepo; this.releaseRepo = releaseRepo; + this.workChangeRepo = workChangeRepo; + this.linkRepo = linkRepo; } @Override - public List perform(OpWorkRequest request) throws ValidationException { + public List perform(User user, OpWorkRequest request) throws ValidationException { WorkChangeData data = validationService.validate(request); - return execute(data.work(), data.ops()); + return execute(user, data.work(), data.ops()); } /** * This is called after validation to perform the update + * @param user the user responsible for the change * @param work the work to link to the operations * @param ops the operations * @return the operations */ - public List execute(Work work, List ops) { - clearOutPriorWorks(ops); + public List execute(User user, Work work, List ops) { + Map> unlinks = clearOutPriorWorks(ops); workService.link(work, ops, true); + recordChanges(user, work, ops, unlinks); return ops; } @@ -188,11 +195,12 @@ public Map> findSampleSlotIdsToRemove(List * * @param ops operations having their works removed + * @return map of ops to their previously linked works */ - public void clearOutPriorWorks(List ops) { + public Map> clearOutPriorWorks(List ops) { Map> opIdWorks = loadOpIdWorks(ops); if (opIdWorks.isEmpty()) { - return; // nothing to do + return opIdWorks; // nothing to do } // A map from work id to work (the works having things removed) Map workIdMap = opIdWorks.values().stream() @@ -217,5 +225,30 @@ public void clearOutPriorWorks(List ops) { work.getOperationIds().removeAll(opIdWorks.keySet()); } workRepo.saveAll(workIdMap.values()); + return opIdWorks; + } + + /** + * Records work changes (which user, which ops and works were links, which were unlinked) + * @param user responsible user + * @param work work being added + * @param ops ops being linked to work + * @param unlinks map from op id to works being unlinked + */ + public void recordChanges(User user, Work work, List ops, Map> unlinks) { + WorkChange change = workChangeRepo.save(new WorkChange(null, user.getId())); + Integer changeId = change.getId(); + Integer workId = work.getId(); + + List links = new ArrayList<>(); + for (Operation op : ops) { + links.add(new WorkChangeLink(changeId, op.getId(), workId, true)); + } + unlinks.forEach((opId, works) -> { + for (Work unwork : works) { + links.add(new WorkChangeLink(changeId, opId, unwork.getId(), false)); + } + }); + linkRepo.saveAll(links); } } diff --git a/src/main/resources/db/changelog/changelog-3.06.xml b/src/main/resources/db/changelog/changelog-3.06.xml new file mode 100644 index 00000000..e310fd49 --- /dev/null +++ b/src/main/resources/db/changelog/changelog-3.06.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/changelog-master.xml b/src/main/resources/db/changelog/changelog-master.xml index a9d034bb..2d53df9e 100644 --- a/src/main/resources/db/changelog/changelog-master.xml +++ b/src/main/resources/db/changelog/changelog-master.xml @@ -35,4 +35,5 @@ + diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java index c28a1347..bc7e3826 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSetOperationWorkMutation.java @@ -18,7 +18,7 @@ import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; import static uk.ac.sanger.sccp.stan.integrationtest.IntegrationTestUtils.chainGet; import static uk.ac.sanger.sccp.utils.BasicUtils.hashSetOf; @@ -49,6 +49,10 @@ public class TestSetOperationWorkMutation { private SnapshotElementRepo snapshotElementRepo; @Autowired private ReleaseRepo releaseRepo; + @Autowired + private WorkChangeRepo workChangeRepo; + @Autowired + private WorkChangeLinkRepo workChangeLinkRepo; private OperationType opType; private User user; @@ -198,5 +202,30 @@ public void testSetOperationWork() throws Exception { // So after the update, work 2 should be linked to ops 1 and 2 with their ss, // work 0 should only be linked to op 2, ss 0-0,3-3 // work 1 should only be linked to release 0, ss 1-1,3-3 + + WorkChange change = onlyItem(workChangeRepo.findAll()); + assertNotNull(change.getId()); + assertEquals(user.getId(), change.getUserId()); + Iterable links = workChangeLinkRepo.findAll(); + List changes = new ArrayList<>(4); + links.forEach(link -> { + assertEquals(change.getId(), link.getWorkChangeId()); + assertNotNull(link.getId()); + changes.add(new int[] {link.getOperationId(), link.getWorkId(), link.isLink() ? 1 : 0}); + }); + assertThat(changes).hasSize(4); + assertThat(changes).containsExactlyInAnyOrder(new int[][] { + {op0.getId(), works[2].getId(), 1}, + {op1.getId(), works[2].getId(), 1}, + {op0.getId(), works[0].getId(), 0}, + {op1.getId(), works[1].getId(), 0}, + }); + } + + private static E onlyItem(Iterable items) { + Iterator iter = items.iterator(); + E item = iter.next(); + assertFalse(iter.hasNext()); + return item; } } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java index 68480f9f..e57e2a64 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/workchange/TestWorkChangeService.java @@ -14,8 +14,7 @@ import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import static uk.ac.sanger.sccp.stan.Matchers.assertValidationException; @@ -35,6 +34,10 @@ class TestWorkChangeService { OperationRepo mockOpRepo; @Mock ReleaseRepo mockReleaseRepo; + @Mock + WorkChangeRepo mockWorkChangeRepo; + @Mock + WorkChangeLinkRepo mockLinkRepo; @InjectMocks WorkChangeServiceImp service; @@ -61,35 +64,41 @@ private static Operation opWithId(int id) { @Test void testPerform_invalid() { List problems = List.of("Bad"); + User user = EntityFactory.getUser(); doThrow(new ValidationException(problems)).when(mockValidationService).validate(any()); OpWorkRequest request = new OpWorkRequest("SGP1", List.of(1)); - assertValidationException(() -> service.perform(request), problems); - verify(service, never()).execute(any(), any()); + assertValidationException(() -> service.perform(user, request), problems); + verify(service, never()).execute(any(), any(), any()); } @Test void testPerform_valid() { + User user = EntityFactory.getUser(); Work work = EntityFactory.makeWork("SGP1"); List ops = List.of(opWithId(1), opWithId(2)); WorkChangeData data = new WorkChangeData(work, ops); when(mockValidationService.validate(any())).thenReturn(data); OpWorkRequest request = new OpWorkRequest("SGP1", List.of(1, 2)); - doReturn(ops).when(service).execute(any(), any()); + doReturn(ops).when(service).execute(any(), any(), any()); - assertSame(ops, service.perform(request)); + assertSame(ops, service.perform(user, request)); verify(mockValidationService).validate(request); - verify(service).execute(work, ops); + verify(service).execute(user, work, ops); } @Test void testExecute() { - Work work = EntityFactory.makeWork("SGP1"); + User user = EntityFactory.getUser(); + Work[] works = EntityFactory.makeWorks("SGP0", "SGP1", "SGP2"); List ops = List.of(opWithId(1), opWithId(2)); - doNothing().when(service).clearOutPriorWorks(any()); - service.execute(work, ops); + Map> unlinks = Map.of(1, Set.of(works[1], works[2])); + doReturn(unlinks).when(service).clearOutPriorWorks(any()); + doNothing().when(service).recordChanges(any(), any(), any(), any()); + service.execute(user, works[0], ops); InOrder inOrder = inOrder(service, mockWorkService); inOrder.verify(service).clearOutPriorWorks(ops); - inOrder.verify(mockWorkService).link(work, ops, true); + inOrder.verify(mockWorkService).link(works[0], ops, true); + inOrder.verify(service).recordChanges(user, works[0], ops, unlinks); } @Test @@ -235,7 +244,7 @@ void testClearOutPriorWorks() { doReturn(opIdWorks).when(service).loadOpIdWorks(ops); doReturn(toRemove).when(service).findSampleSlotIdsToRemove(ops, workIdMap, opIdWorks); - service.clearOutPriorWorks(ops); + Map> removed = service.clearOutPriorWorks(ops); verify(mockWorkRepo).saveAll(sameElements(workIdMap.values(), true)); assertThat(works[0].getSampleSlotIds()).containsExactlyInAnyOrder(new Work.SampleSlotId(22,12)); @@ -243,5 +252,58 @@ void testClearOutPriorWorks() { assertThat(works[0].getOperationIds()).containsExactlyInAnyOrder(3,4); assertThat(works[1].getOperationIds()).containsExactlyInAnyOrder(5); + assertEquals(opIdWorks, removed); + } + + @Test + void testRecordChanges() { + User user = EntityFactory.getUser(); + Work[] works = EntityFactory.makeWorks("SGP1", "SGP2", "SGP3"); + Operation[] ops = IntStream.range(100,103).mapToObj(TestWorkChangeService::opWithId).toArray(Operation[]::new); + final int[] idCounter = {200}; + final List createdChanges = new ArrayList<>(1); + final List createdLinks = new ArrayList<>(); + doAnswer(invocation -> { + WorkChange change = invocation.getArgument(0); + change.setId(idCounter[0]++); + createdChanges.add(change); + return change; + }).when(mockWorkChangeRepo).save(any()); + doAnswer(invocation -> { + List links = invocation.getArgument(0); + for (WorkChangeLink link : links) { + link.setId(idCounter[0]++); + } + createdLinks.addAll(links); + return links; + }).when(mockLinkRepo).saveAll(any()); + Map> unlinks = Map.of( + 100, Set.of(works[1], works[2]), + 101, Set.of(works[2]) + ); + + service.recordChanges(user, works[0], Arrays.asList(ops), unlinks); + verify(mockWorkChangeRepo).save(any()); + verify(mockLinkRepo).saveAll(any()); + assertThat(createdChanges).hasSize(1); + WorkChange change = createdChanges.getFirst(); + Integer changeId = change.getId(); + assertNotNull(changeId); + assertEquals(user.getId(), change.getUserId()); + assertThat(createdLinks).hasSize(6); + List changes = new ArrayList<>(6); + createdLinks.forEach(link -> { + assertNotNull(link.getId()); + assertEquals(changeId, link.getWorkChangeId()); + changes.add(new int[] {link.getOperationId(), link.getWorkId(), link.isLink() ? 1 : 0}); + }); + assertThat(changes).containsExactlyInAnyOrder(new int[][]{ + {ops[0].getId(), works[0].getId(), 1}, + {ops[1].getId(), works[0].getId(), 1}, + {ops[2].getId(), works[0].getId(), 1}, + {ops[0].getId(), works[1].getId(), 0}, + {ops[0].getId(), works[2].getId(), 0}, + {ops[1].getId(), works[2].getId(), 0}, + }); } } \ No newline at end of file From ef5a6f811555fe0f89642fff033ca02efbe54a41 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 23 Apr 2025 18:20:47 +0100 Subject: [PATCH 20/37] Change strip-tube layout again --- src/main/resources/sprint/strip.json | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/main/resources/sprint/strip.json b/src/main/resources/sprint/strip.json index c5e4f4ec..ef5a1a2d 100644 --- a/src/main/resources/sprint/strip.json +++ b/src/main/resources/sprint/strip.json @@ -13,8 +13,8 @@ "cellWidth": 0.25 }, { - "x": 32, - "y": 1, + "x": 34, + "y": 2, "value": "#barcode#", "barcodeType": "datamatrix", "cellWidth": 0.25 @@ -23,13 +23,13 @@ "textFields": [ { "x": 20, - "y": 2, + "y": 4, "value": "#work#", "fontSize": 1.7 }, { - "x": 27, - "y": 2, + "x": 28, + "y": 4, "value": "#lp#", "fontSize": 1.7 }, @@ -40,16 +40,10 @@ "fontSize": 1.8 }, { - "x": 20, - "y": 4, - "value": "#state[0]#", - "fontSize": 1.4 - }, - { - "x": 20, + "x": 19, "y": 6, - "value": "#tissue[0]#", - "fontSize": 1.5 + "value": "#state[0]#", + "fontSize": 1.7 }, { "x": 8, From 30ef01f333f075f8e9f12e9901e2ece944a254e2 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 23 Apr 2025 18:31:47 +0100 Subject: [PATCH 21/37] v3.4.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8c5f2fde..a17fceda 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.4.1 + 3.4.2 stan Spatial Genomics LIMS From 3d9155fae7eed796382dd58124cb6de163eebe20 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:49:11 +0100 Subject: [PATCH 22/37] x1311: Transfer op (i.e. slot copy) copies LP number Make slotcopy copy LP numbers from source labware to destinations where appropriate. I.e. when an LP number isn't specified in the request and the relevant source labware all has the same LP number. --- .../sccp/stan/service/SlotCopyServiceImp.java | 67 ++++++++++-- .../stan/service/TestSlotCopyService.java | 100 +++++++++++++++--- 2 files changed, 139 insertions(+), 28 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java index a058f486..41bf20b2 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java @@ -19,8 +19,7 @@ import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toSet; -import static uk.ac.sanger.sccp.utils.BasicUtils.coalesce; -import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; +import static uk.ac.sanger.sccp.utils.BasicUtils.*; /** * Service to perform an operation copying the contents of slots to new labware @@ -32,6 +31,7 @@ public class SlotCopyServiceImp implements SlotCopyService { static final String CYTASSIST_SLIDE = "Visium LP CytAssist 6.5", CYTASSIST_SLIDE_XL = "Visium LP CytAssist 11", CYTASSIST_SLIDE_HD = "Visium LP CytAssist HD"; static final String EXECUTION_NOTE_NAME = "execution"; + static final String LP_NOTE_NAME = "LP number"; static final String BS_PROBES = "Probes", BS_CDNA = "cDNA", BS_LIBRARY = "Library", BS_LIB_PRE_CLEAN = "Library pre-clean", BS_LIB_POST_CLEAN = "Library post-clean", @@ -114,6 +114,21 @@ public OperationResult record(User user, Data data, final Set barcodesTo return opres; } + /** Loads lp numbers for given labware */ + public UCMap loadLpNumbers(Collection sources) { + List lwIds = sources.stream().map(Labware::getId).toList(); + List notes = lwNoteRepo.findAllByLabwareIdInAndName(lwIds, LP_NOTE_NAME); + if (nullOrEmpty(notes)) { + return new UCMap<>(0); + } + Map lwIdMap = sources.stream().collect(inMap(Labware::getId)); + UCMap lpNumbers = new UCMap<>(notes.size()); + for (LabwareNote note : notes) { + lpNumbers.put(lwIdMap.get(note.getLabwareId()).getBarcode(), note.getValue()); + } + return lpNumbers; + } + // region Executing public OperationResult executeOps(User user, Collection dests, OperationType opType, UCMap lwTypes, UCMap bioStates, @@ -121,11 +136,17 @@ public OperationResult executeOps(User user, Collection des ExecutionType executionType) { List ops = new ArrayList<>(dests.size()); List destLabware = new ArrayList<>(dests.size()); + UCMap sourceLpNumbers; + if (dests.stream().anyMatch(dest -> nullOrEmpty(dest.getLpNumber()))) { + sourceLpNumbers = loadLpNumbers(sources.values()); + } else { + sourceLpNumbers = null; + } for (SlotCopyDestination dest : dests) { OperationResult opres = executeOp(user, dest.getContents(), opType, lwTypes.get(dest.getLabwareType()), - dest.getPreBarcode(), sources, dest.getCosting(), dest.getLotNumber(), dest.getProbeLotNumber(), - bioStates.get(dest.getBioState()), dest.getLpNumber(), existingDests.get(dest.getBarcode()), - executionType); + dest.getPreBarcode(), sources,sourceLpNumbers, dest.getCosting(), dest.getLotNumber(), + dest.getProbeLotNumber(), bioStates.get(dest.getBioState()), dest.getLpNumber(), + existingDests.get(dest.getBarcode()), executionType); ops.addAll(opres.getOperations()); destLabware.addAll(opres.getLabware()); } @@ -136,6 +157,27 @@ public OperationResult executeOps(User user, Collection des return new OperationResult(ops, destLabware); } + /** Finds the LP number for new labware where it can be inherited from the source */ + public String inheritedLpNumber(Collection contents, + UCMap sourceLps) { + if (nullOrEmpty(sourceLps)) { + return null; + } + String destLpNumber = null; + for (SlotCopyContent content : contents) { + String sourceLpNumber = sourceLps.get(content.getSourceBarcode()); + if (nullOrEmpty(sourceLpNumber)) { + return null; + } + if (destLpNumber == null) { + destLpNumber = sourceLpNumber; + } else if (!sourceLpNumber.equalsIgnoreCase(destLpNumber)) { + return null; + } + } + return destLpNumber; + } + /** * Executes the request. Creates the new labware, records the operation, populates the labware, * creates new samples if necessary, discards sources if necessary. @@ -145,7 +187,8 @@ public OperationResult executeOps(User user, Collection des * @param opType the type of operation being performed * @param lwType the type of labware to create * @param preBarcode the prebarcode of the new labware, if it has one - * @param labwareMap a map of the source labware from their barcodes + * @param sourceMap a map of the source labware from their barcodes + * @param sourceLps map of source labware barcode to their LP numbers, if required * @param costing the costing of new labware, if specified * @param lotNumber the lot number of the new labware, if specified * @param probeLotNumber the transcriptome probe lot number, if specified @@ -157,7 +200,8 @@ public OperationResult executeOps(User user, Collection des */ public OperationResult executeOp(User user, Collection contents, OperationType opType, LabwareType lwType, String preBarcode, - UCMap labwareMap, SlideCosting costing, String lotNumber, + UCMap sourceMap, UCMap sourceLps, + SlideCosting costing, String lotNumber, String probeLotNumber, BioState bioState, String lpNumber, Labware destLw, ExecutionType executionType) { if (destLw==null) { @@ -165,10 +209,11 @@ public OperationResult executeOp(User user, Collection contents } else if (bioState==null) { bioState = findBioStateInLabware(destLw); } - Map oldSampleIdToNewSample = createSamples(contents, labwareMap, + Map oldSampleIdToNewSample = createSamples(contents, sourceMap, coalesce(bioState, opType.getNewBioState())); - Labware filledLabware = fillLabware(destLw, contents, labwareMap, oldSampleIdToNewSample); - Operation op = createOperation(user, contents, opType, labwareMap, filledLabware, oldSampleIdToNewSample); + lpNumber = (lpNumber==null ? inheritedLpNumber(contents, sourceLps) : lpNumber.toUpperCase()); + Labware filledLabware = fillLabware(destLw, contents, sourceMap, oldSampleIdToNewSample); + Operation op = createOperation(user, contents, opType, sourceMap, filledLabware, oldSampleIdToNewSample); if (costing != null) { lwNoteRepo.save(new LabwareNote(null, filledLabware.getId(), op.getId(), "costing", costing.name())); } @@ -179,7 +224,7 @@ public OperationResult executeOp(User user, Collection contents lwNoteRepo.save(new LabwareNote(null, filledLabware.getId(), op.getId(), "probe lot", probeLotNumber.toUpperCase())); } if (!nullOrEmpty(lpNumber)) { - lwNoteRepo.save(new LabwareNote(null, filledLabware.getId(), op.getId(), "LP number", lpNumber.toUpperCase())); + lwNoteRepo.save(new LabwareNote(null, filledLabware.getId(), op.getId(), LP_NOTE_NAME, lpNumber)); } if (executionType!=null) { lwNoteRepo.save(new LabwareNote(null, filledLabware.getId(), op.getId(), EXECUTION_NOTE_NAME, executionType.toString())); diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyService.java index e57460b7..3343c5d5 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyService.java @@ -4,8 +4,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.*; import org.mockito.*; -import uk.ac.sanger.sccp.stan.Matchers; import uk.ac.sanger.sccp.stan.*; +import uk.ac.sanger.sccp.stan.Matchers; import uk.ac.sanger.sccp.stan.model.*; import uk.ac.sanger.sccp.stan.repo.*; import uk.ac.sanger.sccp.stan.request.OperationResult; @@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static uk.ac.sanger.sccp.stan.Matchers.sameElements; import static uk.ac.sanger.sccp.stan.service.SlotCopyServiceImp.EXECUTION_NOTE_NAME; /** @@ -195,6 +196,8 @@ public void testExecuteOps() { lw2.setBarcode("STAN-1"); Work work = new Work(50, "SGP50", null, null, null, null, null, null); UCMap sources = UCMap.from(Labware::getBarcode, lw1, lw2); + UCMap sourceLps = new UCMap<>(1); + sourceLps.put(lw1.getBarcode(), "LP99"); final BioState bs = new BioState(10, "bs1"); UCMap bsMap = UCMap.from(BioState::getName, bs); @@ -216,7 +219,8 @@ public void testExecuteOps() { doReturn(new OperationResult(List.of(op1), List.of(newLw1)), new OperationResult(List.of(op2), List.of(newLw2)), new OperationResult(List.of(op3), List.of(dest1))) - .when(service).executeOp(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + .when(service).executeOp(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()); + doReturn(sourceLps).when(service).loadLpNumbers(any()); final ExecutionType exType = ExecutionType.manual; @@ -224,26 +228,28 @@ public void testExecuteOps() { assertThat(result.getOperations()).containsExactly(op1, op2, op3); assertThat(result.getLabware()).containsExactly(newLw1, newLw2, dest1); - verify(service).executeOp(user, dests.get(0).getContents(), opType, lt1, "pb1", sources, SlideCosting.SGP, "1234567", "777777", bs, "LP1", null, exType); - verify(service).executeOp(user, dests.get(1).getContents(), opType, lt2, null, sources, SlideCosting.Faculty, null, null, null, null, null, exType); - verify(service).executeOp(user, dests.get(2).getContents(), opType, null, null, sources, null, null, null, null, null, dest1, exType); + verify(service).loadLpNumbers(sources.values()); + verify(service).executeOp(user, dests.get(0).getContents(), opType, lt1, "pb1", sources, sourceLps, SlideCosting.SGP, "1234567", "777777", bs, "LP1", null, exType); + verify(service).executeOp(user, dests.get(1).getContents(), opType, lt2, null, sources, sourceLps, SlideCosting.Faculty, null, null, null, null, null, exType); + verify(service).executeOp(user, dests.get(2).getContents(), opType, null, null, sources, sourceLps, null, null, null, null, null, dest1, exType); verify(mockBioRiskService).copyOpSampleBioRisks(result.getOperations()); verify(mockWorkService).link(work, result.getOperations()); } @ParameterizedTest - @CsvSource({"false,false,false,false,", - "false,false,false,true,", - "false,true,false,false,", - "false,false,true,false,", - "false,true,true,true,", - "true,false,false,true,", - "true,true,true,true,", - "true,true,true,true,automated", - "true,true,true,true,manual", + @CsvSource({"false,false,false,false,false,", + "false,false,false,true,false,", + "false,true,false,false,false,", + "false,false,true,false,false,", + "false,true,true,true,false,", + "true,false,false,true,false,", + "true,true,true,true,false,", + "true,true,true,true,false,automated", + "true,true,true,true,false,manual", + "true,true,true,false,true,manual", }) - public void testExecuteOp(boolean existingDest, boolean bsInRequest, boolean bsInOpType, boolean hasLp, ExecutionType exType) { + public void testExecuteOp(boolean existingDest, boolean bsInRequest, boolean bsInOpType, boolean hasLp, boolean sourceHasLp, ExecutionType exType) { final User user = EntityFactory.getUser(); final BioState rbs = bsInRequest ? new BioState(5, "requestbs") : null; final BioState obs = bsInOpType ? new BioState(6, "opbs") : null; @@ -254,7 +260,19 @@ public void testExecuteOp(boolean existingDest, boolean bsInRequest, boolean bsI final String probeLotNumber = "777777"; final String lpNumber = (hasLp ? "LP2" : null); LabwareType lt = EntityFactory.getTubeType(); - UCMap sourceMap = UCMap.from(Labware::getBarcode, EntityFactory.getTube()); + final Labware sourceTube = EntityFactory.getTube(); + UCMap sourceMap = UCMap.from(Labware::getBarcode, sourceTube); + UCMap sourceLps; + String sourceLp; + if (sourceHasLp) { + sourceLp = "LP99"; + sourceLps =new UCMap<>(1); + sourceLps.put(sourceTube.getBarcode(), sourceLp); + doReturn(sourceLp).when(service).inheritedLpNumber(any(), any()); + } else { + sourceLp = null; + sourceLps = null; + } Labware destLw = EntityFactory.makeEmptyLabware(lt); if (!existingDest) { when(mockLwService.create(any(), any(), any())).thenReturn(destLw); @@ -271,7 +289,7 @@ public void testExecuteOp(boolean existingDest, boolean bsInRequest, boolean bsI op.setId(50); doReturn(op).when(service).createOperation(any(), any(), any(), any(), any(), any()); - OperationResult opres = service.executeOp(user, contents, opType, lt, preBarcode, sourceMap, costing, lotNumber, probeLotNumber, rbs, lpNumber, existingDest ? destLw : null, exType); + OperationResult opres = service.executeOp(user, contents, opType, lt, preBarcode, sourceMap, sourceLps, costing, lotNumber, probeLotNumber, rbs, lpNumber, existingDest ? destLw : null, exType); assertThat(opres.getLabware()).containsExactly(filledLw); assertThat(opres.getOperations()).containsExactly(op); @@ -293,6 +311,9 @@ public void testExecuteOp(boolean existingDest, boolean bsInRequest, boolean bsI verify(mockLwNoteRepo).save(new LabwareNote(null, filledLw.getId(), op.getId(), "probe lot", probeLotNumber)); if (hasLp) { verify(mockLwNoteRepo).save(new LabwareNote(null, filledLw.getId(), op.getId(), "LP number", lpNumber)); + } else if (sourceHasLp) { + verify(service).inheritedLpNumber(contents, sourceLps); + verify(mockLwNoteRepo).save(new LabwareNote(null, filledLw.getId(), op.getId(), "LP number", sourceLp)); } if (exType!=null) { verify(mockLwNoteRepo).save(new LabwareNote(null, filledLw.getId(), op.getId(), EXECUTION_NOTE_NAME, exType.toString())); @@ -486,4 +507,49 @@ public void testUpdateSources(Labware.State defaultState) { assertThat(barcodesToUnstore).containsExactly("STAN-D"); } } + + @Test + public void testLoadLpNumbers() { + String name = "LP number"; + Labware[] lws = {EntityFactory.getTube(), null, null}; + for (int i = 1; i < lws.length; i++) { + lws[i] = EntityFactory.makeEmptyLabware(lws[0].getLabwareType()); + } + List lwIds = Arrays.stream(lws).map(Labware::getId).toList(); + when(mockLwNoteRepo.findAllByLabwareIdInAndName(any(), any())).thenReturn( + List.of(new LabwareNote(11, lws[1].getId(), 101, name, "LP2"), + new LabwareNote(10, lws[0].getId(), 100, name, "LP1")) + ); + + UCMap lps = service.loadLpNumbers(Arrays.asList(lws)); + verify(mockLwNoteRepo).findAllByLabwareIdInAndName(sameElements(lwIds, true), eq(name)); + assertThat(lps).hasSize(2); + assertEquals("LP1", lps.get(lws[0].getBarcode())); + assertEquals("LP2", lps.get(lws[1].getBarcode())); + } + + @ParameterizedTest + @CsvSource({"LP1,LP1,LP1", + "LP1,,", + ",LP2,", + "LP1,LP2,", + ",,", + }) + public void testInheritedLpNumber(String lp0, String lp1, String expected) { + String[] bcs = {"STAN-1", "STAN-2"}; + UCMap sourceLps = new UCMap<>(); + if (lp0!=null) { + sourceLps.put(bcs[0], lp0); + } + if (lp1!=null) { + sourceLps.put(bcs[1], lp1); + } + sourceLps.put("STAN-3", "LP3"); + List contents = List.of( + new SlotCopyContent(bcs[0], null, null), + new SlotCopyContent(bcs[1], null, null), + new SlotCopyContent(bcs[0], null, null) + ); + assertEquals(expected, service.inheritedLpNumber(contents, sourceLps)); + } } From 91e587a09a024386a83ec405325c9fc93c07d0d7 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 30 Apr 2025 10:00:53 +0100 Subject: [PATCH 23/37] v3.6.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 56201484..ff0dd2cd 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.5.0 + 3.6.0 stan Spatial Genomics LIMS From b53ed52db35b63bafed209ac39bc3da210f2e3e4 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 2 May 2025 10:03:21 +0100 Subject: [PATCH 24/37] 3.6.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ff0dd2cd..1a864678 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.6.0 + 3.6.1 stan Spatial Genomics LIMS From 9daa871efcc7fda5f3e47aacad7200e32ef82302 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Tue, 6 May 2025 17:18:14 +0100 Subject: [PATCH 25/37] From FS#29123: prevent grid problems in store-transfer If someone transfers stored items from a non-grid location to a grid-based location, they would be stored without a grid position, which is confusing. Now * If transferring to a grid-location, check that the items all have valid unoccupied positions specified * If transferring to a non-grid-location, make sure to store them without a position --- .../ac/sanger/sccp/stan/model/store/Size.java | 8 +++++ .../sccp/stan/service/store/StoreService.java | 14 +++++++++ .../stan/service/store/TestStoreService.java | 29 ++++++++++++++----- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/store/Size.java b/src/main/java/uk/ac/sanger/sccp/stan/model/store/Size.java index e7dc3bda..1da586bc 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/model/store/Size.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/store/Size.java @@ -1,5 +1,7 @@ package uk.ac.sanger.sccp.stan.model.store; +import uk.ac.sanger.sccp.stan.model.Address; + /** * A grid size, number of rows and columns * @author dr6 @@ -47,6 +49,12 @@ public Size numColumns(int numColumns) { return this; } + /** Does the given address fit inside this size? */ + public boolean contains(Address address) { + return (address != null && address.getRow() >= 1 && address.getRow() <= this.numRows + && address.getColumn() >= 1 && address.getColumn() <= this.numColumns); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/store/StoreService.java b/src/main/java/uk/ac/sanger/sccp/stan/service/store/StoreService.java index a3ba2735..10eb31be 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/store/StoreService.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/store/StoreService.java @@ -30,6 +30,7 @@ import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toSet; +import static uk.ac.sanger.sccp.utils.BasicUtils.nullOrEmpty; import static uk.ac.sanger.sccp.utils.BasicUtils.repr; /** @@ -532,6 +533,19 @@ public Location transfer(User user, String sourceBarcode, String destinationBarc .filter(item -> stanBarcodes.contains(item.getBarcode().toUpperCase())) .map(item -> new StoreInput(item.getBarcode(), item.getAddress())) .collect(toList()); + Location destination = getLocation(destinationBarcode); + if (destination.getSize()!=null) { + final Size size = destination.getSize(); + if (storeInputs.stream().anyMatch(si -> si.getAddress()==null || !size.contains(si.getAddress()))) { + throw new IllegalArgumentException("Items stored in "+destination.getBarcode()+" must have a suitable row/column address."); + } + Set
requestedAddresses = storeInputs.stream().map(StoreInput::getAddress).collect(toSet()); + if (!nullOrEmpty(destination.getStored()) && destination.getStored().stream().anyMatch(si -> requestedAddresses.contains(si.getAddress()))) { + throw new IllegalArgumentException("Cannot transfer items to "+destination.getBarcode()+" at their current row/column addresses because the positions are occupied."); + } + } else { + storeInputs.forEach(si -> si.setAddress(null)); + } return store(user, storeInputs, destinationBarcode); } } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/store/TestStoreService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/store/TestStoreService.java index cb35078a..bce1ae07 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/store/TestStoreService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/store/TestStoreService.java @@ -618,13 +618,17 @@ public void testCheckErrors() { @ParameterizedTest @CsvSource({ - "STO-0, true, STO-1, STAN-A1 STAN-A2 BLARG,", - "STO-0, true, sto-0,, Source and destination cannot be the same location.", - "STO-0, false, STO-1,, No such location: STO-0", - "STO-0, true, STO-1,, Location STO-0 is empty.", - "STO-0, true, STO-1, BLARG1 BLARG2, None of the labware stored in that location belongs to Stan.", + "STO-0, true, true, STO-1, true, false, STAN-A1 STAN-A2 BLARG,", + "STO-0, true, true, STO-1, true, true, STAN-A1 STAN-A2 BLARG, Cannot transfer items to STO-1 at their current row/column addresses because the positions are occupied.", + "STO-0, false, true, STO-1, true, false, STAN-A1 STAN-A2 BLARG, Items stored in STO-1 must have a suitable row/column address.", + "STO-0, false, true, STO-1, false, false, STAN-A1 STAN-A2 BLARG,", + "STO-0, false, true, sto-0, false, false,, Source and destination cannot be the same location.", + "STO-0, false, false, STO-1, false, false,, No such location: STO-0", + "STO-0, false, true, STO-1, false, false,, Location STO-0 is empty.", + "STO-0, false, true, STO-1, false, false, BLARG1 BLARG2, None of the labware stored in that location belongs to Stan.", }) - public void testTransfer(String sourceBarcode, boolean sourceExists, String destinationBarcode, + public void testTransfer(String sourceBarcode, boolean allInGrid, boolean sourceExists, String destinationBarcode, boolean destIsGrid, + boolean gridClash, String joinedItemBarcodes, String expectedError) { Location source; if (sourceExists) { @@ -635,12 +639,21 @@ public void testTransfer(String sourceBarcode, boolean sourceExists, String dest source = null; doThrow(new RuntimeException("No such location: "+sourceBarcode)).when(service).getLocation(sourceBarcode); } + Location destination = new Location(); + destination.setBarcode(destinationBarcode.toUpperCase()); + doReturn(destination).when(service).getLocation(destinationBarcode); + if (destIsGrid) { + destination.setSize(new Size(5,5)); + if (gridClash) { + destination.getStored().add(new StoredItem("STAN-X", destination, new Address(1,1))); + } + } List stored; Set stanBarcodes; if (joinedItemBarcodes!=null) { final String[] itemBarcodes = joinedItemBarcodes.split("\\s+"); stored = IntStream.range(0, itemBarcodes.length) - .mapToObj(i -> new StoredItem(itemBarcodes[i], source, i==0 ? new Address(1,1) : null)) + .mapToObj(i -> new StoredItem(itemBarcodes[i], source, i==0 || allInGrid ? new Address(1,i+1) : null)) .collect(toList()); stanBarcodes = Arrays.stream(itemBarcodes) .map(String::toUpperCase) @@ -668,7 +681,7 @@ public void testTransfer(String sourceBarcode, boolean sourceExists, String dest List expectedStoreInputs = stored.stream() .filter(item -> stanBarcodes.contains(item.getBarcode().toUpperCase())) - .map(item -> new StoreInput(item.getBarcode(), item.getAddress())) + .map(item -> new StoreInput(item.getBarcode(), destIsGrid ? item.getAddress() : null)) .collect(toList()); verify(service).store(user, expectedStoreInputs, destinationBarcode); From cfa9c070abf9531dcfa4e7689838956dd4005054 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 7 May 2025 09:42:00 +0100 Subject: [PATCH 26/37] v3.6.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1a864678..ceab41aa 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.6.1 + 3.6.2 stan Spatial Genomics LIMS From bef7f6979b1e3935332820feea74bf1380ca7139 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 7 May 2025 15:45:02 +0100 Subject: [PATCH 27/37] Add TissueType.code to graphql schema --- src/main/resources/schema.graphqls | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index af51b210..0ee52d9f 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -102,7 +102,10 @@ type Fixative { """The type of tissue, typically an organ.""" type TissueType { + """The name of the tissue type.""" name: String! + """The short code for the tissue type.""" + code: String! """The possible spatial locations for tissue of this type.""" spatialLocations: [SpatialLocation!]! } From 040927dd0ef7ac787a1396f12d63d420b551702a Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Fri, 9 May 2025 16:44:58 +0100 Subject: [PATCH 28/37] v3.6.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ceab41aa..0b5ea838 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.6.2 + 3.6.3 stan Spatial Genomics LIMS From 9803a0997147f044f39ce66edb24a079ab18e2a5 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Mon, 12 May 2025 13:18:07 +0100 Subject: [PATCH 29/37] x1289: confirm section request now has work number per labware --- .../confirm/ConfirmSectionLabware.java | 22 ++++++++-- .../confirm/ConfirmSectionRequest.java | 28 ++----------- .../confirm/ConfirmSectionServiceImp.java | 22 ++++++++-- .../confirm/ConfirmSectionValidation.java | 13 +++++- .../ConfirmSectionValidationServiceImp.java | 12 +++--- src/main/resources/schema.graphqls | 4 +- .../confirm/TestConfirmSectionService.java | 42 ++++++++++++++----- .../TestConfirmSectionValidationService.java | 42 +++++++++---------- .../resources/graphql/confirmsection.graphql | 4 +- .../graphql/confirmsection_simple.graphql | 2 +- 10 files changed, 117 insertions(+), 74 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/confirm/ConfirmSectionLabware.java b/src/main/java/uk/ac/sanger/sccp/stan/request/confirm/ConfirmSectionLabware.java index d41d2419..5c8f2848 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/confirm/ConfirmSectionLabware.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/confirm/ConfirmSectionLabware.java @@ -17,21 +17,23 @@ public class ConfirmSectionLabware { private boolean cancelled; private List confirmSections; private List addressComments; + private String workNumber; public ConfirmSectionLabware() { - this(null, false, null, null); + this(null, false, null, null, null); } public ConfirmSectionLabware(String barcode) { - this(barcode, false, null, null); + this(barcode, false, null, null, null); } public ConfirmSectionLabware(String barcode, boolean cancelled, Iterable confirmSections, - Iterable addressComments) { + Iterable addressComments, String workNumber) { setBarcode(barcode); setCancelled(cancelled); setConfirmSections(confirmSections); setAddressComments(addressComments); + setWorkNumber(workNumber); } public String getBarcode() { @@ -66,6 +68,14 @@ public void setAddressComments(Iterable addressComments) { this.addressComments = newArrayList(addressComments); } + public String getWorkNumber() { + return this.workNumber; + } + + public void setWorkNumber(String workNumber) { + this.workNumber = workNumber; + } + @Override public String toString() { return BasicUtils.describe("ConfirmSectionLabware") @@ -73,6 +83,8 @@ public String toString() { .add("cancelled", cancelled) .add("confirmSections", confirmSections) .add("addressComments", addressComments) + .add("workNumber", workNumber) + .reprStringValues() .toString(); } @@ -84,7 +96,9 @@ public boolean equals(Object o) { return (this.cancelled == that.cancelled && Objects.equals(this.barcode, that.barcode) && Objects.equals(this.confirmSections, that.confirmSections) - && Objects.equals(this.addressComments, that.addressComments)); + && Objects.equals(this.addressComments, that.addressComments) + && Objects.equals(this.workNumber, that.workNumber) + ); } @Override diff --git a/src/main/java/uk/ac/sanger/sccp/stan/request/confirm/ConfirmSectionRequest.java b/src/main/java/uk/ac/sanger/sccp/stan/request/confirm/ConfirmSectionRequest.java index 595ba215..5e5f6bf5 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/request/confirm/ConfirmSectionRequest.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/request/confirm/ConfirmSectionRequest.java @@ -1,9 +1,6 @@ package uk.ac.sanger.sccp.stan.request.confirm; -import uk.ac.sanger.sccp.utils.BasicUtils; - import java.util.List; -import java.util.Objects; import static uk.ac.sanger.sccp.utils.BasicUtils.newArrayList; @@ -13,19 +10,13 @@ */ public class ConfirmSectionRequest { private List labware; - private String workNumber; - - public ConfirmSectionRequest(Iterable labware, String workNumber) { - setLabware(labware); - this.workNumber = workNumber; - } public ConfirmSectionRequest(Iterable labware) { - this(labware, null); + setLabware(labware); } public ConfirmSectionRequest() { - this(null, null); + this(null); } public List getLabware() { @@ -36,20 +27,12 @@ public void setLabware(Iterable labware) { this.labware = newArrayList(labware); } - public String getWorkNumber() { - return this.workNumber; - } - - public void setWorkNumber(String workNumber) { - this.workNumber = workNumber; - } - @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ConfirmSectionRequest that = (ConfirmSectionRequest) o; - return (this.labware.equals(that.labware) && Objects.equals(this.workNumber, that.workNumber)); + return this.labware.equals(that.labware); } @Override @@ -59,9 +42,6 @@ public int hashCode() { @Override public String toString() { - return BasicUtils.describe("ConfirmSectionRequest") - .add("labware", labware) - .addRepr("workNumber", workNumber) - .toString(); + return String.format("ConfirmSectionRequest(%s)", labware); } } diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionServiceImp.java index e29ab3e3..7c2653d1 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionServiceImp.java @@ -89,6 +89,7 @@ public OperationResult perform(User user, ConfirmSectionValidation validation, C .map(PlanOperation::getId) .collect(toSet()); var plansNotes = loadPlanNotes(planIds); + Map opWork = new HashMap<>(request.getLabware().size()); for (ConfirmSectionLabware csl : request.getLabware()) { Labware lw = labwareMap.get(csl.getBarcode()); if (lw==null) { @@ -109,17 +110,32 @@ public OperationResult perform(User user, ConfirmSectionValidation validation, C recordAddressComments(csl, clr.operation==null ? null : clr.operation.getId(), clr.labware); if (clr.operation!=null) { operations.add(clr.operation); + if (!nullOrEmpty(csl.getWorkNumber())) { + opWork.put(clr.operation, csl.getWorkNumber()); + } } resultLabware.add(clr.labware); } - if (request.getWorkNumber()!=null) { - workService.link(request.getWorkNumber(), operations); - } + linkWorks(opWork, validation.getWorks()); bioRiskService.copyOpSampleBioRisks(operations); updateSourceBlocks(operations); return new OperationResult(operations, resultLabware); } + /** + * Links ops to the specified works + * @param opWork map of op to work number + * @param works map of work number to work + */ + public void linkWorks(Map opWork, UCMap works) { + Map> workOps = new HashMap<>(); + opWork.forEach((op, wn) -> { + Work work = works.get(wn); + workOps.computeIfAbsent(work, k -> new ArrayList<>()).add(op); + }); + workOps.forEach(workService::link); + } + /** * Loads the labware notes for the given plan ids. * @param planIds the labware notes to look up diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionValidation.java b/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionValidation.java index 2fb9d014..b81e60c1 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionValidation.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionValidation.java @@ -15,6 +15,7 @@ public class ConfirmSectionValidation { private final Map lwPlans; private final UCMap slotRegions; private final Map comments; + private final UCMap works; public ConfirmSectionValidation(Collection problems) { this.problems = problems; @@ -22,15 +23,17 @@ public ConfirmSectionValidation(Collection problems) { this.lwPlans = null; this.slotRegions = null; this.comments = null; + this.works = null; } public ConfirmSectionValidation(UCMap labware, Map lwPlans, - UCMap slotRegions, Map comments) { + UCMap slotRegions, Map comments, UCMap works) { this.problems = List.of(); this.labware = labware; this.lwPlans = lwPlans; this.slotRegions = slotRegions; this.comments = comments; + this.works = works; } public Collection getProblems() { @@ -53,6 +56,10 @@ public Map getComments() { return this.comments; } + public UCMap getWorks() { + return this.works; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -62,7 +69,9 @@ public boolean equals(Object o) { && Objects.equals(this.labware, that.labware) && Objects.equals(this.lwPlans, that.lwPlans) && Objects.equals(this.slotRegions, that.slotRegions) - && Objects.equals(this.comments, that.comments)); + && Objects.equals(this.comments, that.comments) + && Objects.equals(this.works, that.works) + ); } @Override diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionValidationServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionValidationServiceImp.java index ed94f709..7b613904 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionValidationServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/operation/confirm/ConfirmSectionValidationServiceImp.java @@ -5,10 +5,8 @@ import uk.ac.sanger.sccp.stan.model.*; import uk.ac.sanger.sccp.stan.repo.LabwareRepo; import uk.ac.sanger.sccp.stan.repo.PlanOperationRepo; -import uk.ac.sanger.sccp.stan.request.confirm.ConfirmSection; -import uk.ac.sanger.sccp.stan.request.confirm.ConfirmSectionLabware; +import uk.ac.sanger.sccp.stan.request.confirm.*; import uk.ac.sanger.sccp.stan.request.confirm.ConfirmSectionLabware.AddressCommentId; -import uk.ac.sanger.sccp.stan.request.confirm.ConfirmSectionRequest; import uk.ac.sanger.sccp.stan.service.CommentValidationService; import uk.ac.sanger.sccp.stan.service.SlotRegionService; import uk.ac.sanger.sccp.stan.service.work.WorkService; @@ -59,11 +57,15 @@ public ConfirmSectionValidation validate(ConfirmSectionRequest request) { Map plans = lookUpPlans(problems, labware.values()); validateOperations(problems, request.getLabware(), labware, plans); Map commentIdMap = validateCommentIds(problems, request.getLabware()); - workService.validateUsableWork(problems, request.getWorkNumber()); + Set workNumbers = request.getLabware().stream() + .map(ConfirmSectionLabware::getWorkNumber) + .filter(wn -> !nullOrEmpty(wn)) + .collect(toSet()); + UCMap works = workService.validateUsableWorks(problems, workNumbers); if (!problems.isEmpty()) { return new ConfirmSectionValidation(problems); } - return new ConfirmSectionValidation(labware, plans, slotRegions, commentIdMap); + return new ConfirmSectionValidation(labware, plans, slotRegions, commentIdMap, works); } /** diff --git a/src/main/resources/schema.graphqls b/src/main/resources/schema.graphqls index 0ee52d9f..5ddc64a6 100644 --- a/src/main/resources/schema.graphqls +++ b/src/main/resources/schema.graphqls @@ -578,6 +578,8 @@ input ConfirmSection { input ConfirmSectionLabware { """The barcode of the labware.""" barcode: String! + """The work number to link to the operation.""" + workNumber: String """Should the whole labware be cancelled? Default false.""" cancelled: Boolean """What individual sections, if any, should be created in the labware?""" @@ -590,8 +592,6 @@ input ConfirmSectionLabware { input ConfirmSectionRequest { """The specification of what to confirm or cancel in each labware.""" labware: [ConfirmSectionLabware!]! - """An optional work number to associate with the operations.""" - workNumber: String } """A specification that the contents of one slot should be copied to a particular address in new labware.""" diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/operation/confirm/TestConfirmSectionService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/operation/confirm/TestConfirmSectionService.java index 1da5a7ae..552d2fd5 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/operation/confirm/TestConfirmSectionService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/operation/confirm/TestConfirmSectionService.java @@ -90,7 +90,7 @@ public void testConfirmOperationValid() { Labware lw = EntityFactory.getTube(); ConfirmSectionRequest request = new ConfirmSectionRequest(List.of(new ConfirmSectionLabware("STAN-01"))); ConfirmSectionValidation validation = new ConfirmSectionValidation(UCMap.from(Labware::getBarcode, lw), - Map.of(), new UCMap<>(), Map.of()); + Map.of(), new UCMap<>(), Map.of(), new UCMap<>()); doReturn(validation).when(mockValidationService).validate(request); OperationResult opResult = new OperationResult(List.of(), List.of(lw)); doReturn(opResult).when(service).perform(user, validation, request); @@ -113,7 +113,7 @@ static Stream performErrorArgs() { Arguments.of(makeValidation(lw), new ConfirmSectionRequest(List.of(new ConfirmSectionLabware(lw.getBarcode()))), "No plan found for labware "+lw.getBarcode()), - Arguments.of(new ConfirmSectionValidation(new UCMap<>(), Map.of(lw.getId(), plan), new UCMap<>(), Map.of()), + Arguments.of(new ConfirmSectionValidation(new UCMap<>(), Map.of(lw.getId(), plan), new UCMap<>(), Map.of(), new UCMap<>()), new ConfirmSectionRequest(List.of(new ConfirmSectionLabware(lw.getBarcode()))), "Invalid labware barcode: "+lw.getBarcode()) ); @@ -121,7 +121,7 @@ static Stream performErrorArgs() { private static ConfirmSectionValidation makeValidation(Labware lw) { UCMap lwMap = UCMap.from(Labware::getBarcode, lw); - return new ConfirmSectionValidation(lwMap, Map.of(), new UCMap<>(0), Map.of()); + return new ConfirmSectionValidation(lwMap, Map.of(), new UCMap<>(0), Map.of(), new UCMap<>(0)); } private static LabwareNote planNote(Integer id, Integer lwId, Integer planId, String name, String value) { @@ -156,6 +156,8 @@ public void testPerformSuccessful() { ); UCMap regionMap = UCMap.from(SlotRegion::getName, new SlotRegion(1, "Top")); Map commentMap = Map.of(1, new Comment(1, "com", "cat")); + Work work = EntityFactory.makeWork("SGP99"); + UCMap works = UCMap.from(Work::getWorkNumber, work); when(mockLwNoteRepo.findAllByPlanIdIn(any())).thenReturn(planNotes); @@ -165,7 +167,9 @@ public void testPerformSuccessful() { ConfirmLabwareResult clr2 = new ConfirmLabwareResult(null, lw2B); ConfirmSectionLabware csl1 = new ConfirmSectionLabware("STAN-01"); + csl1.setWorkNumber("SGP99"); ConfirmSectionLabware csl2 = new ConfirmSectionLabware("STAN-02"); + csl2.setWorkNumber("SGP99"); doReturn(clr1).when(service).confirmLabware(user, csl1, lw1, plan1, regionMap, commentMap); doReturn(clr2).when(service).confirmLabware(user, csl2, lw2, plan2, regionMap, commentMap); @@ -174,9 +178,9 @@ public void testPerformSuccessful() { doNothing().when(service).updateSourceBlocks(any()); doNothing().when(service).updateNotes(any(), any(), any()); - ConfirmSectionRequest request = new ConfirmSectionRequest(List.of(csl1, csl2), "SGP9000"); + ConfirmSectionRequest request = new ConfirmSectionRequest(List.of(csl1, csl2)); ConfirmSectionValidation validation = new ConfirmSectionValidation(UCMap.from(Labware::getBarcode, lw1, lw2), - Map.of(lw1.getId(), plan1, lw2.getId(), plan2), regionMap, commentMap); + Map.of(lw1.getId(), plan1, lw2.getId(), plan2), regionMap, commentMap, works); OperationResult result = service.perform(user, validation, request); assertThat(result.getLabware()).containsExactly(lw1B, lw2B); @@ -187,7 +191,7 @@ public void testPerformSuccessful() { verify(service).recordAddressComments(csl1, op1.getId(), lw1B); verify(service).recordAddressComments(csl2, null, lw2B); verify(mockBioRiskService).copyOpSampleBioRisks(result.getOperations()); - verify(mockWorkService).link(request.getWorkNumber(), result.getOperations()); + verify(service).linkWorks(Map.of(op1, "SGP99"), works); verify(service).updateSourceBlocks(result.getOperations()); verify(service).loadPlanNotes(Set.of(10,11)); verify(service).updateNotes(planNotes, op1.getId(), lw1.getId()); @@ -259,7 +263,7 @@ public void testConfirmLabwareMissingPlanAction() { UCMap regionMap = UCMap.from(SlotRegion::getName, new SlotRegion(1, "Top")); Map commentMap = Map.of(1, new Comment(1, "com", "cat")); ConfirmSectionLabware csl = new ConfirmSectionLabware(lw.getBarcode(), false, - List.of(new ConfirmSection(A1, sample.getId(), 12)), null); + List.of(new ConfirmSection(A1, sample.getId(), 12)), null, null); assertThat(assertThrows(IllegalArgumentException.class, () -> service.confirmLabware(EntityFactory.getUser(), csl, lw, plan, regionMap, commentMap))) @@ -289,7 +293,7 @@ public void testConfirmLabware() { new ConfirmSection(A1, sample.getId(), 11, null, "top"), new ConfirmSection(B3, sample.getId(), 12, null, "top") ); - ConfirmSectionLabware csl = new ConfirmSectionLabware(lw1.getBarcode(), false, csecs, List.of()); + ConfirmSectionLabware csl = new ConfirmSectionLabware(lw1.getBarcode(), false, csecs, List.of(), null); Map planActionMap = Stream.of( new PlanAction(1, 1, source, lw1.getSlot(A1), sample), new PlanAction(2, 1, source, lw1.getSlot(B3), sample, 12, "50", null) @@ -433,7 +437,7 @@ public void testRecordCommentsValid() { new AddressCommentId(A1, 1), new AddressCommentId(B3, 1), new AddressCommentId(B3, 2) - )); + ), null); final int opId = 909; final Comment comment1 = new Comment(1, "Tastes of pink", "Section"); final Comment comment2 = new Comment(2, "Looks backwards", "Section"); @@ -453,6 +457,24 @@ public void testRecordCommentsValid() { verify(mockOpCommentRepo).saveAll(Matchers.sameElements(expectedOpComments, true)); } + @Test + public void testLinkWorks() { + Work[] works = EntityFactory.makeWorks("SGP0", "SGP1"); + UCMap workMap = UCMap.from(Work::getWorkNumber, works); + Operation[] ops = IntStream.range(100,103) + .mapToObj(i -> { + Operation op = new Operation(); + op.setId(i); + return op; + }).toArray(Operation[]::new); + Map opWorkNumbers = Map.of(ops[0], works[0].getWorkNumber(), + ops[1], works[0].getWorkNumber(), + ops[2], works[1].getWorkNumber()); + service.linkWorks(opWorkNumbers, workMap); + verify(mockWorkService).link(same(works[0]), Matchers.sameElements(List.of(ops[0], ops[1]), true)); + verify(mockWorkService).link(works[1], List.of(ops[2])); + } + @Test public void testGetPlanActionMap() { Sample[] samples = IntStream.range(1,3) @@ -474,7 +496,7 @@ public void testGetPlanActionMap() { }; Map map = service.getPlanActionMap(Arrays.asList(pas), 10); - assertEquals(map.size(), 3); + assertEquals(3, map.size()); assertEquals(pas[0], map.get(new PlanActionKey(A1, samples[0].getId()))); assertEquals(pas[1], map.get(new PlanActionKey(A1, samples[1].getId()))); assertEquals(pas[2], map.get(new PlanActionKey(A2, samples[0].getId()))); diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/operation/confirm/TestConfirmSectionValidationService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/operation/confirm/TestConfirmSectionValidationService.java index 0c88d870..5a08059d 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/operation/confirm/TestConfirmSectionValidationService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/operation/confirm/TestConfirmSectionValidationService.java @@ -1,22 +1,16 @@ package uk.ac.sanger.sccp.stan.service.operation.confirm; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.*; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import uk.ac.sanger.sccp.stan.EntityFactory; import uk.ac.sanger.sccp.stan.model.*; import uk.ac.sanger.sccp.stan.repo.LabwareRepo; import uk.ac.sanger.sccp.stan.repo.PlanOperationRepo; -import uk.ac.sanger.sccp.stan.request.confirm.ConfirmSection; -import uk.ac.sanger.sccp.stan.request.confirm.ConfirmSectionLabware; +import uk.ac.sanger.sccp.stan.request.confirm.*; import uk.ac.sanger.sccp.stan.request.confirm.ConfirmSectionLabware.AddressCommentId; -import uk.ac.sanger.sccp.stan.request.confirm.ConfirmSectionRequest; import uk.ac.sanger.sccp.stan.service.CommentValidationService; import uk.ac.sanger.sccp.stan.service.SlotRegionService; import uk.ac.sanger.sccp.stan.service.work.WorkService; @@ -84,14 +78,16 @@ public void testValidateNoLabware() { @ParameterizedTest public void testValidate(boolean valid) { Labware lw = EntityFactory.getTube(); - ConfirmSectionRequest request = new ConfirmSectionRequest(List.of(new ConfirmSectionLabware(lw.getBarcode())), - "SGP9000"); + final ConfirmSectionLabware csl = new ConfirmSectionLabware(lw.getBarcode()); + csl.setWorkNumber("SGP1"); + ConfirmSectionRequest request = new ConfirmSectionRequest(List.of(csl)); UCMap lwMap = UCMap.from(Labware::getBarcode, lw); PlanOperation plan = EntityFactory.makePlanForLabware(opType, List.of(), List.of()); Map planMap = Map.of(lw.getId(), plan); UCMap regionMap = UCMap.from(SlotRegion::getName, new SlotRegion(1, "Top")); Map commentMap = Map.of(1, new Comment(1, "com", "cat")); + UCMap works = UCMap.from(Work::getWorkNumber, EntityFactory.makeWork("SGP1")); mayAddProblem(valid ? null : "lw problem", lwMap).when(service).validateLabware(any(), any()); mayAddProblem(valid ? null : "plan problem", planMap).when(service).lookUpPlans(any(), any()); @@ -99,6 +95,7 @@ public void testValidate(boolean valid) { mayAddProblem(valid ? null : "region problem", regionMap).when(service).validateSlotRegions(any(), any()); mayAddProblem(valid ? null : "missing region").when(service).requireRegionsForMultiSampleSlots(any(), any()); mayAddProblem(valid ? null : "comment problem", commentMap).when(service).validateCommentIds(any(), any()); + mayAddProblem(valid ? null : "work problem", works).when(mockWorkService).validateUsableWorks(any(), any()); var validation = service.validate(request); if (valid) { @@ -107,16 +104,17 @@ public void testValidate(boolean valid) { assertEquals(validation.getLabware(), lwMap); assertEquals(validation.getComments(), commentMap); assertEquals(validation.getSlotRegions(), regionMap); + assertEquals(validation.getWorks(), works); } else { assertThat(validation.getProblems()).containsExactlyInAnyOrder( - "lw problem", "plan problem", "op problem", "region problem", "missing region", "comment problem" + "lw problem", "plan problem", "op problem", "region problem", "missing region", "comment problem", "work problem" ); } verify(service).validateCommentIds(any(), eq(request.getLabware())); verify(service).validateSlotRegions(any(), eq(request.getLabware())); verify(service).requireRegionsForMultiSampleSlots(any(), eq(request.getLabware())); - verify(mockWorkService).validateUsableWork(any(), eq(request.getWorkNumber())); + verify(mockWorkService).validateUsableWorks(any(), eq(Set.of("SGP1"))); verify(service).validateLabware(any(), eq(request.getLabware())); verify(service).lookUpPlans(any(), eq(lwMap.values())); verify(service).validateOperations(any(), eq(request.getLabware()), eq(lwMap), eq(planMap)); @@ -131,10 +129,10 @@ public void testValidateCommentIds(boolean valid) { cs.setCommentIds(List.of(10,11)); List csls = List.of( new ConfirmSectionLabware("STAN-1"), - new ConfirmSectionLabware("STAN-2", false, List.of(cs), List.of()), + new ConfirmSectionLabware("STAN-2", false, List.of(cs), List.of(), null), new ConfirmSectionLabware("STAN-3", false, List.of(), List.of(new AddressCommentId(new Address(1,2), 11), - new AddressCommentId(new Address(1,4), 12))) + new AddressCommentId(new Address(1,4), 12)), null) ); Map commentMap = Map.of(10, new Comment(10, "com", "cat"), 11, new Comment(11, "com1", "cat"), @@ -169,15 +167,15 @@ static Stream validateRegionsArgs() { List allRegions = List.of(new SlotRegion(1, "Top"), new SlotRegion(2, "Bottom"), new SlotRegion(3, "Middle")); ConfirmSectionLabware cslNoRegion = new ConfirmSectionLabware("STAN-1", false, - List.of(confirmSection(A1, null), confirmSection(A1, "")), List.of()); - ConfirmSectionLabware cslNoBarcode = new ConfirmSectionLabware("", false, List.of(), List.of()); + List.of(confirmSection(A1, null), confirmSection(A1, "")), List.of(), null); + ConfirmSectionLabware cslNoBarcode = new ConfirmSectionLabware("", false, List.of(), List.of(), null); ConfirmSectionLabware cslRegions = new ConfirmSectionLabware("STAN-2", false, List.of(confirmSection(A1, "Top"), confirmSection(A1, "Bottom"), - confirmSection(A2, "Top")), List.of()); + confirmSection(A2, "Top")), List.of(), null); ConfirmSectionLabware cslUnknownRegion = new ConfirmSectionLabware("STAN-3", false, - List.of(confirmSection(A1, "Spoon")), List.of()); + List.of(confirmSection(A1, "Spoon")), List.of(), null); ConfirmSectionLabware cslRepeatedRegions = new ConfirmSectionLabware("STAN-4", false, - List.of(confirmSection(A1, "Top"), confirmSection(A1, "top")), List.of()); + List.of(confirmSection(A1, "Top"), confirmSection(A1, "top")), List.of(), null); return Arrays.stream(new Object[][] { {allRegions, List.of(), List.of(cslNoRegion, cslNoBarcode), List.of()}, {allRegions, allRegions, List.of(cslNoRegion, cslRegions), List.of()}, @@ -339,10 +337,10 @@ public void testValidateOperations() { final Address A1 = new Address(1,1); final ConfirmSectionLabware csl1 = new ConfirmSectionLabware(lw1.getBarcode(), false, List.of(new ConfirmSection(A1, sample.getId(), 12)), - List.of(new AddressCommentId(A1, 4))); + List.of(new AddressCommentId(A1, 4)), null); final ConfirmSectionLabware csl2 = new ConfirmSectionLabware(lw2.getBarcode(), false, List.of(new ConfirmSection(A1, sample.getId(), 15)), - List.of(new AddressCommentId(A1, 5))); + List.of(new AddressCommentId(A1, 5)), null); List csls = List.of( csl1, csl2, new ConfirmSectionLabware("STAN-404"), diff --git a/src/test/resources/graphql/confirmsection.graphql b/src/test/resources/graphql/confirmsection.graphql index aed92566..678f3dc4 100644 --- a/src/test/resources/graphql/confirmsection.graphql +++ b/src/test/resources/graphql/confirmsection.graphql @@ -3,6 +3,7 @@ mutation { labware: [ { barcode: "$BARCODE0", + workNumber: "SGP4000" confirmSections: [ { destinationAddress: "A1" @@ -38,6 +39,7 @@ mutation { }, { barcode: "$BARCODE1" + workNumber: "SGP4000" confirmSections: [ { destinationAddress: "A1" @@ -47,6 +49,7 @@ mutation { }, { barcode: "$BARCODE2" + workNumber: "SGP4000" confirmSections: [ { destinationAddress: "A1" @@ -55,7 +58,6 @@ mutation { ] } ] - workNumber: "SGP4000" }) { labware { id diff --git a/src/test/resources/graphql/confirmsection_simple.graphql b/src/test/resources/graphql/confirmsection_simple.graphql index 2366c60c..70456e04 100644 --- a/src/test/resources/graphql/confirmsection_simple.graphql +++ b/src/test/resources/graphql/confirmsection_simple.graphql @@ -3,6 +3,7 @@ mutation { labware: [ { barcode: "BARCODE0", + workNumber: "SGP1" confirmSections: [ { destinationAddress: "A1" @@ -12,7 +13,6 @@ mutation { ] } ] - workNumber: "SGP1" }) { labware { barcode From 214c7b19460d6f77789f7679f1625fb0e55768d6 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Tue, 13 May 2025 12:54:11 +0100 Subject: [PATCH 30/37] x1313: rename two lw types --- .../ac/sanger/sccp/stan/service/SlotCopyServiceImp.java | 2 +- .../sccp/stan/integrationtest/TestSlotCopyMutation.java | 2 +- .../sccp/stan/service/TestSlotCopyValidationService.java | 8 ++++---- src/test/resources/graphql/cytassist.graphql | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java index 41bf20b2..12d64b18 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java @@ -28,7 +28,7 @@ @Service public class SlotCopyServiceImp implements SlotCopyService { static final String CYTASSIST_OP = "CytAssist"; - static final String CYTASSIST_SLIDE = "Visium LP CytAssist 6.5", CYTASSIST_SLIDE_XL = "Visium LP CytAssist 11", + static final String CYTASSIST_SLIDE = "CytAssist 6.5 Visium LP", CYTASSIST_SLIDE_XL = "CytAssist 11 Visium LP", CYTASSIST_SLIDE_HD = "Visium LP CytAssist HD"; static final String EXECUTION_NOTE_NAME = "execution"; static final String LP_NOTE_NAME = "LP number"; diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSlotCopyMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSlotCopyMutation.java index 378f4e9f..8e043724 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSlotCopyMutation.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSlotCopyMutation.java @@ -65,7 +65,7 @@ public class TestSlotCopyMutation { public void testSlotCopy(boolean cytAssist) throws Exception { OperationType cytOpType = null; if (cytAssist) { - lwTypeRepo.save(new LabwareType(null, "Visium LP CytAssist 6.5", 4, 1, null, true)); + lwTypeRepo.save(new LabwareType(null, "CytAssist 6.5 Visium LP", 4, 1, null, true)); BioState bs = entityCreator.createBioState("Probes"); cytOpType = entityCreator.createOpType("CytAssist", bs, OperationTypeFlag.MARK_SOURCE_USED); } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java index 6706dd35..f5fa3181 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java @@ -607,12 +607,12 @@ public void testValidateCytOp_nolwtype() { @ParameterizedTest @CsvSource({ - ", Visium LP CytAssist 6.5, 1 4", - ", Visium LP CytAssist 11, 1 2", + ", CytAssist 6.5 Visium LP, 1 4", + ", CytAssist 11 Visium LP, 1 2", ", Visium LP CytAssist HD, 1 2", "Expected a CytAssist labware type for operation CytAssist., Bananas, 1", - "Slots B1 and C1 are disallowed for use in this operation., Visium LP CytAssist 6.5, 1 2", - "Slots B1 and C1 are disallowed for use in this operation., Visium LP CytAssist 6.5, 3 4", + "Slots B1 and C1 are disallowed for use in this operation., CytAssist 6.5 Visium LP, 1 2", + "Slots B1 and C1 are disallowed for use in this operation., CytAssist 6.5 Visium LP, 3 4", }) public void testValidateCytOp(String expectedProblem, String lwTypeName, String joinedRows) { List contents = Arrays.stream(joinedRows.split("\\s+")) diff --git a/src/test/resources/graphql/cytassist.graphql b/src/test/resources/graphql/cytassist.graphql index c6051341..52a7c5ee 100644 --- a/src/test/resources/graphql/cytassist.graphql +++ b/src/test/resources/graphql/cytassist.graphql @@ -4,7 +4,7 @@ mutation { workNumber: "SGP5000" destinations: [{ preBarcode: "V42A20-3752023-10-20" - labwareType: "Visium LP CytAssist 6.5" + labwareType: "CytAssist 6.5 Visium LP" costing: Faculty lotNumber: "1234567" probeLotNumber: "7777777" From 196bc1462d843f695d9b3810857b96b7ee154021 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 14 May 2025 09:50:19 +0100 Subject: [PATCH 31/37] v3.7.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 0b5ea838..1001cdbe 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.6.3 + 3.7.0 stan Spatial Genomics LIMS From f1a3d5e65d50b233c93627dea570a7c8868630a8 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 14 May 2025 11:55:02 +0100 Subject: [PATCH 32/37] x1280 rename lw type cytassist hd and change layout Now has four slots but the middle two are disallowed --- .../uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java | 2 +- .../sanger/sccp/stan/service/SlotCopyValidationServiceImp.java | 3 ++- .../sccp/stan/service/TestSlotCopyValidationService.java | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java index 12d64b18..88dba001 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java @@ -29,7 +29,7 @@ public class SlotCopyServiceImp implements SlotCopyService { static final String CYTASSIST_OP = "CytAssist"; static final String CYTASSIST_SLIDE = "CytAssist 6.5 Visium LP", CYTASSIST_SLIDE_XL = "CytAssist 11 Visium LP", - CYTASSIST_SLIDE_HD = "Visium LP CytAssist HD"; + CYTASSIST_SLIDE_HD = "CytAssist HD 6.5 Visium LP"; static final String EXECUTION_NOTE_NAME = "execution"; static final String LP_NOTE_NAME = "LP number"; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyValidationServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyValidationServiceImp.java index 2f48af84..67a45af8 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyValidationServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyValidationServiceImp.java @@ -452,7 +452,8 @@ public void validateCytOp(Collection problems, Collection 1 && ad.getRow() < 4) { diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java index f5fa3181..757c7c5e 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java @@ -609,9 +609,10 @@ public void testValidateCytOp_nolwtype() { @CsvSource({ ", CytAssist 6.5 Visium LP, 1 4", ", CytAssist 11 Visium LP, 1 2", - ", Visium LP CytAssist HD, 1 2", + ", CytAssist HD 6.5 Visium LP, 1 4", "Expected a CytAssist labware type for operation CytAssist., Bananas, 1", "Slots B1 and C1 are disallowed for use in this operation., CytAssist 6.5 Visium LP, 1 2", + "Slots B1 and C1 are disallowed for use in this operation., CytAssist HD 6.5 Visium LP, 1 2", "Slots B1 and C1 are disallowed for use in this operation., CytAssist 6.5 Visium LP, 3 4", }) public void testValidateCytOp(String expectedProblem, String lwTypeName, String joinedRows) { From 9d8e8cf664cec2814b16619554bf04be5d12a230 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 14 May 2025 14:06:50 +0100 Subject: [PATCH 33/37] x1280 move cytassist lwtype logic into LabwareType class --- .../ac/sanger/sccp/stan/model/LabwareType.java | 17 ++++++++++++++++- .../sccp/stan/service/SlotCopyServiceImp.java | 2 -- .../service/SlotCopyValidationServiceImp.java | 11 ++++------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java b/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java index 02a8c35e..6906b44e 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java @@ -13,7 +13,10 @@ public class LabwareType implements HasIntId, HasName { public static final String FETAL_WASTE_NAME = "Fetal waste container", PROVIASETTE_NAME = "Proviasette", CASSETTE_NAME = "Cassette", - XENIUM_NAME = "Xenium"; + XENIUM_NAME = "Xenium", + CYTASSIST_SLIDE_NAME = "CytAssist 6.5 Visium LP", + CYTASSIST_SLIDE_XL_NAME = "CytAssist 11 Visium LP", + CYTASSIST_SLIDE_HD_NAME = "CytAssist HD 6.5 Visium LP"; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -103,6 +106,18 @@ public boolean isXenium() { return (name!=null && name.equalsIgnoreCase(XENIUM_NAME)); } + public boolean isCytAssist() { + return (name != null && (name.equalsIgnoreCase(CYTASSIST_SLIDE_NAME) + || name.equalsIgnoreCase(CYTASSIST_SLIDE_XL_NAME) + || name.equalsIgnoreCase(CYTASSIST_SLIDE_HD_NAME))); + } + + /** Should slots B1 and C1 be blocked in cytassist op? */ + public boolean blockMiddleSlots() { + return (name != null && (name.equalsIgnoreCase(CYTASSIST_SLIDE_NAME) + || name.equalsIgnoreCase(CYTASSIST_SLIDE_HD_NAME))); + } + /** * Should samples be listed in column-major order on the label? */ diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java index 88dba001..94ab34d2 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyServiceImp.java @@ -28,8 +28,6 @@ @Service public class SlotCopyServiceImp implements SlotCopyService { static final String CYTASSIST_OP = "CytAssist"; - static final String CYTASSIST_SLIDE = "CytAssist 6.5 Visium LP", CYTASSIST_SLIDE_XL = "CytAssist 11 Visium LP", - CYTASSIST_SLIDE_HD = "CytAssist HD 6.5 Visium LP"; static final String EXECUTION_NOTE_NAME = "execution"; static final String LP_NOTE_NAME = "LP number"; diff --git a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyValidationServiceImp.java b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyValidationServiceImp.java index 67a45af8..57b784fe 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyValidationServiceImp.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/service/SlotCopyValidationServiceImp.java @@ -15,7 +15,8 @@ import java.util.regex.Pattern; import static java.util.stream.Collectors.toSet; -import static uk.ac.sanger.sccp.stan.service.SlotCopyServiceImp.*; +import static uk.ac.sanger.sccp.stan.service.SlotCopyServiceImp.CYTASSIST_OP; +import static uk.ac.sanger.sccp.stan.service.SlotCopyServiceImp.VALID_BS_UPPER; import static uk.ac.sanger.sccp.utils.BasicUtils.*; /** @@ -447,13 +448,9 @@ public void validateOps(Collection problems, List s */ public void validateCytOp(Collection problems, Collection contents, LabwareType lwType) { if (lwType != null) { - if (!lwType.getName().equalsIgnoreCase(CYTASSIST_SLIDE) - && !lwType.getName().equalsIgnoreCase(CYTASSIST_SLIDE_XL) - && !lwType.getName().equalsIgnoreCase(CYTASSIST_SLIDE_HD)) { + if (!lwType.isCytAssist()) { problems.add(String.format("Expected a CytAssist labware type for operation %s.", CYTASSIST_OP)); - } - if ((lwType.getName().equalsIgnoreCase(CYTASSIST_SLIDE) || lwType.getName().equalsIgnoreCase(CYTASSIST_SLIDE_HD)) - && contents != null && !contents.isEmpty()) { + } else if (lwType.blockMiddleSlots() && contents != null && !contents.isEmpty()) { for (SlotCopyContent content : contents) { Address ad = content.getDestinationAddress(); if (ad != null && ad.getColumn()==1 && ad.getRow() > 1 && ad.getRow() < 4) { From f111223d586b9feb832b95076dca75344bb781c9 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Wed, 14 May 2025 15:28:38 +0100 Subject: [PATCH 34/37] v3.7.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1001cdbe..37560051 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.7.0 + 3.7.1 stan Spatial Genomics LIMS From 3539281d4d0ed3b7e459dae71fb8f0d583ed2535 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 15 May 2025 09:58:11 +0100 Subject: [PATCH 35/37] Remove "Visium LP" from CytAssist lw type names --- .../uk/ac/sanger/sccp/stan/model/LabwareType.java | 6 +++--- .../stan/integrationtest/TestSlotCopyMutation.java | 2 +- .../stan/service/TestSlotCopyValidationService.java | 12 ++++++------ src/test/resources/graphql/cytassist.graphql | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java b/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java index 6906b44e..cddcdac6 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java @@ -14,9 +14,9 @@ public class LabwareType implements HasIntId, HasName { PROVIASETTE_NAME = "Proviasette", CASSETTE_NAME = "Cassette", XENIUM_NAME = "Xenium", - CYTASSIST_SLIDE_NAME = "CytAssist 6.5 Visium LP", - CYTASSIST_SLIDE_XL_NAME = "CytAssist 11 Visium LP", - CYTASSIST_SLIDE_HD_NAME = "CytAssist HD 6.5 Visium LP"; + CYTASSIST_SLIDE_NAME = "CytAssist 6.5", + CYTASSIST_SLIDE_XL_NAME = "CytAssist 11", + CYTASSIST_SLIDE_HD_NAME = "CytAssist HD 6.5"; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSlotCopyMutation.java b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSlotCopyMutation.java index 8e043724..94f76f7f 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSlotCopyMutation.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/integrationtest/TestSlotCopyMutation.java @@ -65,7 +65,7 @@ public class TestSlotCopyMutation { public void testSlotCopy(boolean cytAssist) throws Exception { OperationType cytOpType = null; if (cytAssist) { - lwTypeRepo.save(new LabwareType(null, "CytAssist 6.5 Visium LP", 4, 1, null, true)); + lwTypeRepo.save(new LabwareType(null, "CytAssist 6.5", 4, 1, null, true)); BioState bs = entityCreator.createBioState("Probes"); cytOpType = entityCreator.createOpType("CytAssist", bs, OperationTypeFlag.MARK_SOURCE_USED); } diff --git a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java index 757c7c5e..b47e80f3 100644 --- a/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java +++ b/src/test/java/uk/ac/sanger/sccp/stan/service/TestSlotCopyValidationService.java @@ -607,13 +607,13 @@ public void testValidateCytOp_nolwtype() { @ParameterizedTest @CsvSource({ - ", CytAssist 6.5 Visium LP, 1 4", - ", CytAssist 11 Visium LP, 1 2", - ", CytAssist HD 6.5 Visium LP, 1 4", + ", CytAssist 6.5, 1 4", + ", CytAssist 11, 1 2", + ", CytAssist HD 6.5, 1 4", "Expected a CytAssist labware type for operation CytAssist., Bananas, 1", - "Slots B1 and C1 are disallowed for use in this operation., CytAssist 6.5 Visium LP, 1 2", - "Slots B1 and C1 are disallowed for use in this operation., CytAssist HD 6.5 Visium LP, 1 2", - "Slots B1 and C1 are disallowed for use in this operation., CytAssist 6.5 Visium LP, 3 4", + "Slots B1 and C1 are disallowed for use in this operation., CytAssist 6.5, 1 2", + "Slots B1 and C1 are disallowed for use in this operation., CytAssist HD 6.5, 1 2", + "Slots B1 and C1 are disallowed for use in this operation., CytAssist 6.5, 3 4", }) public void testValidateCytOp(String expectedProblem, String lwTypeName, String joinedRows) { List contents = Arrays.stream(joinedRows.split("\\s+")) diff --git a/src/test/resources/graphql/cytassist.graphql b/src/test/resources/graphql/cytassist.graphql index 52a7c5ee..5baed8b2 100644 --- a/src/test/resources/graphql/cytassist.graphql +++ b/src/test/resources/graphql/cytassist.graphql @@ -4,7 +4,7 @@ mutation { workNumber: "SGP5000" destinations: [{ preBarcode: "V42A20-3752023-10-20" - labwareType: "CytAssist 6.5 Visium LP" + labwareType: "CytAssist 6.5" costing: Faculty lotNumber: "1234567" probeLotNumber: "7777777" From f3e52f6264548b3d1dafdeacd3bd86df890c6bed Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 15 May 2025 11:48:37 +0100 Subject: [PATCH 36/37] x1312 allow for more cytassist lwtypes, some with A1/D1 layout --- .../ac/sanger/sccp/stan/model/LabwareType.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java b/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java index cddcdac6..b16f2754 100644 --- a/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java +++ b/src/main/java/uk/ac/sanger/sccp/stan/model/LabwareType.java @@ -1,8 +1,11 @@ package uk.ac.sanger.sccp.stan.model; import javax.persistence.*; +import java.util.List; import java.util.Objects; +import static uk.ac.sanger.sccp.utils.BasicUtils.containsIgnoreCase; + /** * A type of labware. This indicates the layout of the labware, its name, what kind of labels it uses, * and might indicate other functional changes for certain operations. @@ -13,10 +16,10 @@ public class LabwareType implements HasIntId, HasName { public static final String FETAL_WASTE_NAME = "Fetal waste container", PROVIASETTE_NAME = "Proviasette", CASSETTE_NAME = "Cassette", - XENIUM_NAME = "Xenium", - CYTASSIST_SLIDE_NAME = "CytAssist 6.5", - CYTASSIST_SLIDE_XL_NAME = "CytAssist 11", - CYTASSIST_SLIDE_HD_NAME = "CytAssist HD 6.5"; + XENIUM_NAME = "Xenium"; + public static final List CYTASSIST_BLOCK_MIDDLE_NAMES = List.of( + "CytAssist 6.5", "CytAssist HD 6.5", "CytAssist HD 3' 6.5" + ); @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -107,15 +110,12 @@ public boolean isXenium() { } public boolean isCytAssist() { - return (name != null && (name.equalsIgnoreCase(CYTASSIST_SLIDE_NAME) - || name.equalsIgnoreCase(CYTASSIST_SLIDE_XL_NAME) - || name.equalsIgnoreCase(CYTASSIST_SLIDE_HD_NAME))); + return (name != null && containsIgnoreCase(name, "CytAssist")); } /** Should slots B1 and C1 be blocked in cytassist op? */ public boolean blockMiddleSlots() { - return (name != null && (name.equalsIgnoreCase(CYTASSIST_SLIDE_NAME) - || name.equalsIgnoreCase(CYTASSIST_SLIDE_HD_NAME))); + return (name != null && CYTASSIST_BLOCK_MIDDLE_NAMES.stream().anyMatch(name::equalsIgnoreCase)); } /** From 69cb2584e0112c9f382905ee6e9e7ab144be36e2 Mon Sep 17 00:00:00 2001 From: David Robinson <14000840+khelwood@users.noreply.github.com> Date: Thu, 15 May 2025 16:53:57 +0100 Subject: [PATCH 37/37] v3.8.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 37560051..58ec7990 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ uk.ac.sanger.sccp stan - 3.7.1 + 3.8.0 stan Spatial Genomics LIMS