From 5de75cd9943980153c3b891408189ad9e2881656 Mon Sep 17 00:00:00 2001 From: nielserik Date: Fri, 9 May 2025 21:47:08 +0200 Subject: [PATCH 1/4] Use-at-location (reading room) loans - new properties in loan policy - new properties in loan - two new APIs to switch between held and in-use - new held/in-use circulation log event types --- descriptors/ModuleDescriptor-template.json | 14 +++++ .../circulation/CirculationVerticle.java | 5 +- .../folio/circulation/domain/EventType.java | 4 +- .../org/folio/circulation/domain/Loan.java | 14 ++++- .../folio/circulation/domain/LoanAction.java | 6 +- .../domain/LoanCheckInService.java | 4 ++ .../circulation/domain/policy/LoanPolicy.java | 5 ++ .../representations/LoanProperties.java | 7 ++- .../logs/LogContextActionResolver.java | 4 ++ .../representations/logs/LogEventType.java | 4 +- .../resources/CheckInByBarcodeResource.java | 1 + .../resources/CheckInProcessAdapter.java | 9 +++ .../resources/CheckOutByBarcodeResource.java | 10 ++++ .../ChangeUsageStatusByBarcodeRequest.java | 40 +++++++++++++ .../HoldByBarcodeResource.java | 58 +++++++++++++++++++ .../PickUpByBarcodeResource.java | 58 +++++++++++++++++++ .../UsageStatusChangeResource.java | 50 ++++++++++++++++ .../circulation/services/EventPublisher.java | 5 ++ 18 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/folio/circulation/resources/foruseatlocation/ChangeUsageStatusByBarcodeRequest.java create mode 100644 src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java create mode 100644 src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java create mode 100644 src/main/java/org/folio/circulation/resources/foruseatlocation/UsageStatusChangeResource.java diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 751c082bf9..b035324c8f 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -272,6 +272,20 @@ "circulation.renew-loan.all" ] }, + { + "methods": [ + "POST" + ], + "pathPattern": "/circulation/pickup-by-barcode-for-use-at-location", + "permissionsRequired": [] + }, + { + "methods": [ + "POST" + ], + "pathPattern": "/circulation/hold-by-barcode-for-use-at-location", + "permissionsRequired": [] + }, { "methods": [ "GET" diff --git a/src/main/java/org/folio/circulation/CirculationVerticle.java b/src/main/java/org/folio/circulation/CirculationVerticle.java index 3cf5912167..207f19d4a7 100644 --- a/src/main/java/org/folio/circulation/CirculationVerticle.java +++ b/src/main/java/org/folio/circulation/CirculationVerticle.java @@ -52,6 +52,8 @@ import org.folio.circulation.resources.handlers.LoanRelatedFeeFineClosedHandlerResource; import org.folio.circulation.resources.renewal.RenewByBarcodeResource; import org.folio.circulation.resources.renewal.RenewByIdResource; +import org.folio.circulation.resources.foruseatlocation.HoldByBarcodeResource; +import org.folio.circulation.resources.foruseatlocation.PickUpByBarcodeResource; import org.folio.circulation.support.logging.LogHelper; import org.folio.circulation.support.logging.Logging; @@ -97,7 +99,8 @@ public void start(Promise startFuture) { new RenewByBarcodeResource(client).register(router); new RenewByIdResource(client).register(router); - + new HoldByBarcodeResource(client).register(router); + new PickUpByBarcodeResource(client).register(router); new AllowedServicePointsResource(client).register(router); new LoanCollectionResource(client).register(router); new RequestCollectionResource(client).register(router); diff --git a/src/main/java/org/folio/circulation/domain/EventType.java b/src/main/java/org/folio/circulation/domain/EventType.java index 7c94f2d9c0..6ed9973906 100644 --- a/src/main/java/org/folio/circulation/domain/EventType.java +++ b/src/main/java/org/folio/circulation/domain/EventType.java @@ -10,5 +10,7 @@ public enum EventType { LOAN_DUE_DATE_CHANGED, LOAN_CLOSED, LOG_RECORD, - LOAN_RELATED_FEE_FINE_CLOSED + LOAN_RELATED_FEE_FINE_CLOSED, + ITEM_HELD_FOR_USE_AT_LOCATION, + ITEM_PICKED_UP_FOR_USE_AT_LOCATION } diff --git a/src/main/java/org/folio/circulation/domain/Loan.java b/src/main/java/org/folio/circulation/domain/Loan.java index 50bc07100b..3916ba9eb8 100644 --- a/src/main/java/org/folio/circulation/domain/Loan.java +++ b/src/main/java/org/folio/circulation/domain/Loan.java @@ -21,6 +21,8 @@ import static org.folio.circulation.domain.representations.LoanProperties.ACTION_COMMENT; import static org.folio.circulation.domain.representations.LoanProperties.AGED_TO_LOST_DATE; import static org.folio.circulation.domain.representations.LoanProperties.AGED_TO_LOST_DELAYED_BILLING; +import static org.folio.circulation.domain.representations.LoanProperties.AT_LOCATION_USE_STATUS; +import static org.folio.circulation.domain.representations.LoanProperties.AT_LOCATION_USE_STATUS_DATE; import static org.folio.circulation.domain.representations.LoanProperties.BILL_DATE; import static org.folio.circulation.domain.representations.LoanProperties.BILL_NUMBER; import static org.folio.circulation.domain.representations.LoanProperties.CHECKIN_SERVICE_POINT_ID; @@ -29,6 +31,7 @@ import static org.folio.circulation.domain.representations.LoanProperties.DATE_LOST_ITEM_SHOULD_BE_BILLED; import static org.folio.circulation.domain.representations.LoanProperties.DECLARED_LOST_DATE; import static org.folio.circulation.domain.representations.LoanProperties.DUE_DATE; +import static org.folio.circulation.domain.representations.LoanProperties.FOR_USE_AT_LOCATION; import static org.folio.circulation.domain.representations.LoanProperties.ITEM_LOCATION_ID_AT_CHECKOUT; import static org.folio.circulation.domain.representations.LoanProperties.ITEM_STATUS; import static org.folio.circulation.domain.representations.LoanProperties.LAST_FEE_BILLED; @@ -205,6 +208,16 @@ public String getAction() { return getProperty(representation, ACTION); } + public Loan changeStatusOfUsageAtLocation(String usageStatus) { + writeByPath(representation, usageStatus, FOR_USE_AT_LOCATION, AT_LOCATION_USE_STATUS); + writeByPath(representation, ClockUtil.getZonedDateTime().toString(), FOR_USE_AT_LOCATION, AT_LOCATION_USE_STATUS_DATE); + return this; + } + + public boolean isForUseAtLocation() { + return representation.containsKey(FOR_USE_AT_LOCATION); + } + private void changeCheckInServicePointId(UUID servicePointId) { log.debug("changeCheckInServicePointId:: parameters servicePointId: {}", servicePointId); write(representation, "checkinServicePointId", servicePointId); @@ -734,7 +747,6 @@ private void changeClaimedReturnedDate(ZonedDateTime claimedReturnedDate) { public Loan closeLoan(LoanAction action) { log.debug("closeLoan:: parameters action: {}", action); changeStatus(LoanStatus.CLOSED); - changeAction(action); removeActionComment(); diff --git a/src/main/java/org/folio/circulation/domain/LoanAction.java b/src/main/java/org/folio/circulation/domain/LoanAction.java index 87fb1dbc7d..ed85cc6f8a 100644 --- a/src/main/java/org/folio/circulation/domain/LoanAction.java +++ b/src/main/java/org/folio/circulation/domain/LoanAction.java @@ -23,9 +23,9 @@ public enum LoanAction { STAFF_INFO_ADDED("staffInfoAdded"), RESOLVE_CLAIM_AS_RETURNED_BY_PATRON("checkedInReturnedByPatron"), RESOLVE_CLAIM_AS_FOUND_BY_LIBRARY("checkedInFoundByLibrary"), - - REMINDER_FEE("reminderFee"); - + REMINDER_FEE("reminderFee"), + HELD_FOR_USE_AT_LOCATION("heldForUseAtLocation"), + PICKED_UP_FOR_USE_AT_LOCATION ("pickedUpForUseAtLocation"); private final String value; LoanAction(String value) { diff --git a/src/main/java/org/folio/circulation/domain/LoanCheckInService.java b/src/main/java/org/folio/circulation/domain/LoanCheckInService.java index 78bf759466..2456005560 100644 --- a/src/main/java/org/folio/circulation/domain/LoanCheckInService.java +++ b/src/main/java/org/folio/circulation/domain/LoanCheckInService.java @@ -39,6 +39,10 @@ public Result checkIn(Loan loan, ZonedDateTime systemDateTime, loan.removeAgedToLostBillingInfo(); } + if (loan.isForUseAtLocation()) { + loan.changeStatusOfUsageAtLocation("Returned"); + } + return succeeded(loan.checkIn(request.getCheckInDate(), systemDateTime, request.getServicePointId())); } diff --git a/src/main/java/org/folio/circulation/domain/policy/LoanPolicy.java b/src/main/java/org/folio/circulation/domain/policy/LoanPolicy.java index 26d9039b3a..26fd8f96cb 100644 --- a/src/main/java/org/folio/circulation/domain/policy/LoanPolicy.java +++ b/src/main/java/org/folio/circulation/domain/policy/LoanPolicy.java @@ -50,6 +50,7 @@ public class LoanPolicy extends Policy { private static final String ALTERNATE_RENEWAL_LOAN_PERIOD_KEY = "alternateRenewalLoanPeriod"; private static final String ALLOW_RECALLS_TO_EXTEND_OVERDUE_LOANS = "allowRecallsToExtendOverdueLoans"; private static final String ALTERNATE_RECALL_RETURN_INTERVAL = "alternateRecallReturnInterval"; + private static final String FOR_USE_AT_LOCATION = "forUseAtLocation"; private static final String INTERVAL_ID = "intervalId"; private static final String DURATION = "duration"; @@ -345,6 +346,10 @@ public boolean isNotLoanable() { return !isLoanable(); } + public boolean isForUseAtLocation() { + return getBooleanProperty(getLoansPolicy(), FOR_USE_AT_LOCATION); + } + public DueDateManagement getDueDateManagement() { JsonObject loansPolicyObj = getLoansPolicy(); if (Objects.isNull(loansPolicyObj)) { diff --git a/src/main/java/org/folio/circulation/domain/representations/LoanProperties.java b/src/main/java/org/folio/circulation/domain/representations/LoanProperties.java index e95e86bc24..6361a0684a 100644 --- a/src/main/java/org/folio/circulation/domain/representations/LoanProperties.java +++ b/src/main/java/org/folio/circulation/domain/representations/LoanProperties.java @@ -34,7 +34,12 @@ private LoanProperties() { } public static final String DATE_LOST_ITEM_SHOULD_BE_BILLED = "dateLostItemShouldBeBilled"; public static final String METADATA = "metadata"; public static final String UPDATED_BY_USER_ID = "updatedByUserId"; - + public static final String FOR_USE_AT_LOCATION = "forUseAtLocation"; + public static final String AT_LOCATION_USE_STATUS = "status"; + public static final String AT_LOCATION_USE_STATUS_DATE = "statusDate"; + public static final String USAGE_STATUS_IN_USE = "In use"; + public static final String USAGE_STATUS_HELD = "Held"; + public static final String USAGE_STATUS_RETURNED = "Returned"; public static final String REMINDERS = "reminders"; public static final String LAST_FEE_BILLED = "lastFeeBilled"; public static final String BILL_NUMBER = "number"; diff --git a/src/main/java/org/folio/circulation/domain/representations/logs/LogContextActionResolver.java b/src/main/java/org/folio/circulation/domain/representations/logs/LogContextActionResolver.java index 78056f8de8..3465252bcd 100644 --- a/src/main/java/org/folio/circulation/domain/representations/logs/LogContextActionResolver.java +++ b/src/main/java/org/folio/circulation/domain/representations/logs/LogContextActionResolver.java @@ -7,8 +7,10 @@ import static org.folio.circulation.domain.LoanAction.CLOSED_LOAN; import static org.folio.circulation.domain.LoanAction.DECLARED_LOST; import static org.folio.circulation.domain.LoanAction.DUE_DATE_CHANGED; +import static org.folio.circulation.domain.LoanAction.HELD_FOR_USE_AT_LOCATION; import static org.folio.circulation.domain.LoanAction.ITEM_AGED_TO_LOST; import static org.folio.circulation.domain.LoanAction.MISSING; +import static org.folio.circulation.domain.LoanAction.PICKED_UP_FOR_USE_AT_LOCATION; import static org.folio.circulation.domain.LoanAction.RECALLREQUESTED; import static org.folio.circulation.domain.LoanAction.RENEWED; import static org.folio.circulation.domain.LoanAction.RENEWED_THROUGH_OVERRIDE; @@ -39,6 +41,8 @@ public class LogContextActionResolver { loanLogActions.put(DUE_DATE_CHANGED.getValue(), "Changed due date"); loanLogActions.put(PATRON_INFO_ADDED.getValue(), "Patron info added"); loanLogActions.put(STAFF_INFO_ADDED.getValue(), "Staff info added"); + loanLogActions.put(HELD_FOR_USE_AT_LOCATION.getValue(),"Held for use at location"); + loanLogActions.put(PICKED_UP_FOR_USE_AT_LOCATION.getValue(), "Picked up for use at location"); } public static String resolveAction(String action) { diff --git a/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java b/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java index c78301c543..1cdfada2c0 100644 --- a/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java +++ b/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java @@ -11,7 +11,9 @@ public enum LogEventType { REQUEST_CREATED_THROUGH_OVERRIDE("REQUEST_CREATED_THROUGH_OVERRIDE_EVENT"), REQUEST_UPDATED("REQUEST_UPDATED_EVENT"), REQUEST_MOVED("REQUEST_MOVED_EVENT"), - REQUEST_REORDERED("REQUEST_REORDERED_EVENT"); + REQUEST_REORDERED("REQUEST_REORDERED_EVENT"), + HELD_FOR_USE_AT_LOCATION("HELD_FOR_USE_AT_LOCATION_EVENT"), + PICKED_UP_FOR_USE_AT_LOCATION("PICKED_UP_FOR_USE_AT_LOCATION_EVENT"); private final String value; diff --git a/src/main/java/org/folio/circulation/resources/CheckInByBarcodeResource.java b/src/main/java/org/folio/circulation/resources/CheckInByBarcodeResource.java index f55c6eabe1..8a25890f4b 100644 --- a/src/main/java/org/folio/circulation/resources/CheckInByBarcodeResource.java +++ b/src/main/java/org/folio/circulation/resources/CheckInByBarcodeResource.java @@ -105,6 +105,7 @@ private void checkIn(RoutingContext routingContext) { processAdapter::findSingleOpenLoan, CheckInContext::withLoan)) .thenComposeAsync(findLoanResult -> findLoanResult.combineAfter( processAdapter::checkInLoan, CheckInContext::withLoan)) + .thenApply(r -> r.map(processAdapter::markReturnedIfForUseAtLocation)) .thenComposeAsync(checkInLoan -> checkInLoan.combineAfter( processAdapter::updateRequestQueue, CheckInContext::withRequestQueue)) .thenComposeAsync(r -> r.after(processAdapter::findFulfillableRequest)) diff --git a/src/main/java/org/folio/circulation/resources/CheckInProcessAdapter.java b/src/main/java/org/folio/circulation/resources/CheckInProcessAdapter.java index 0515c3c304..5c8caf4564 100644 --- a/src/main/java/org/folio/circulation/resources/CheckInProcessAdapter.java +++ b/src/main/java/org/folio/circulation/resources/CheckInProcessAdapter.java @@ -1,6 +1,7 @@ package org.folio.circulation.resources; import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.folio.circulation.domain.representations.LoanProperties.USAGE_STATUS_RETURNED; import static org.folio.circulation.support.results.Result.succeeded; import java.lang.invoke.MethodHandles; @@ -274,6 +275,14 @@ CheckInContext setInHouseUse(CheckInContext checkInContext) { checkInContext.getCheckInRequest())); } + CheckInContext markReturnedIfForUseAtLocation(CheckInContext checkInContext) { + Loan loan = checkInContext.getLoan(); + if (loan != null && loan.isForUseAtLocation()) { + loan.changeStatusOfUsageAtLocation(USAGE_STATUS_RETURNED); + } + return checkInContext; + } + public CompletableFuture> logCheckInOperation( CheckInContext checkInContext) { diff --git a/src/main/java/org/folio/circulation/resources/CheckOutByBarcodeResource.java b/src/main/java/org/folio/circulation/resources/CheckOutByBarcodeResource.java index 2467dc4a1d..81da450f09 100644 --- a/src/main/java/org/folio/circulation/resources/CheckOutByBarcodeResource.java +++ b/src/main/java/org/folio/circulation/resources/CheckOutByBarcodeResource.java @@ -3,6 +3,7 @@ import static java.util.concurrent.CompletableFuture.completedFuture; import static org.folio.circulation.domain.ItemStatus.CHECKED_OUT; import static org.folio.circulation.domain.LoanAction.CHECKED_OUT_THROUGH_OVERRIDE; +import static org.folio.circulation.domain.representations.LoanProperties.USAGE_STATUS_IN_USE; import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FETCH_ITEM; import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FETCH_PROXY_USER; import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FETCH_USER; @@ -178,6 +179,7 @@ CompletableFuture> checkOut(CheckOutByBarcodeReque .thenApply(r -> r.next(this::setItemLocationIdAtCheckout)) .thenComposeAsync(r -> r.after(records -> checkOut(records, clients))) .thenApply(r -> r.map(this::checkOutItem)) + .thenApply(r -> r.map(this::markInUseIfForUseAtLocation)) .thenCompose(r -> r.after(l -> acquireLockIfNeededOrFail(settingsRepository, checkOutLockRepository, l, checkOutLockId, validators, errorHandler))) .thenComposeAsync(r -> r.after(requestQueueUpdate::onCheckOut)) @@ -305,6 +307,14 @@ private LoanAndRelatedRecords checkOutItem(LoanAndRelatedRecords loanAndRelatedR return loanAndRelatedRecords.changeItemStatus(CHECKED_OUT); } + private LoanAndRelatedRecords markInUseIfForUseAtLocation(LoanAndRelatedRecords loanAndRelatedRecords) { + Loan loan = loanAndRelatedRecords.getLoan(); + if (loan.getLoanPolicy().isForUseAtLocation()) { + loan.changeStatusOfUsageAtLocation(USAGE_STATUS_IN_USE); + } + return loanAndRelatedRecords; + } + private Result createdLoanFrom(Result result, CirculationErrorHandler errorHandler) { diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/ChangeUsageStatusByBarcodeRequest.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/ChangeUsageStatusByBarcodeRequest.java new file mode 100644 index 0000000000..3d4ec45a83 --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/ChangeUsageStatusByBarcodeRequest.java @@ -0,0 +1,40 @@ +package org.folio.circulation.resources.foruseatlocation; + +import io.vertx.core.json.JsonObject; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.folio.circulation.support.results.Result; + +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.folio.circulation.support.ValidationErrorFailure.failedValidation; +import static org.folio.circulation.support.json.JsonPropertyFetcher.getProperty; +import static org.folio.circulation.support.results.Result.succeeded; + +@Getter +@AllArgsConstructor +public class ChangeUsageStatusByBarcodeRequest { + private static final String ITEM_BARCODE = "itemBarcode"; + private static final String USER_BARCODE = "userBarcode"; + + private final String itemBarcode; + private final String userBarcode; + + static Result usageStatusChangeRequestFrom(JsonObject json) { + final String itemBarcode = getProperty(json, ITEM_BARCODE); + + if (isBlank(itemBarcode)) { + return failedValidation("Request to change at-location-use status must have an item barcode", + ITEM_BARCODE, null); + } + + final String userBarcode = getProperty(json, USER_BARCODE); + + if (isBlank(userBarcode)) { + return failedValidation("Request to change at-location-use status must have a user barcode", + USER_BARCODE, null); + } + + return succeeded(new ChangeUsageStatusByBarcodeRequest(itemBarcode, userBarcode)); + + } +} diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java new file mode 100644 index 0000000000..50f0897bf1 --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java @@ -0,0 +1,58 @@ +package org.folio.circulation.resources.foruseatlocation; + +import io.vertx.core.http.HttpClient; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import org.folio.circulation.domain.Loan; +import org.folio.circulation.domain.LoanAction; +import org.folio.circulation.domain.representations.logs.LogEventType; +import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; +import org.folio.circulation.infrastructure.storage.loans.LoanRepository; +import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.resources.handlers.error.CirculationErrorHandler; +import org.folio.circulation.resources.handlers.error.OverridingErrorHandler; +import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.support.Clients; +import org.folio.circulation.support.RouteRegistration; +import org.folio.circulation.support.http.OkapiPermissions; +import org.folio.circulation.support.http.server.WebContext; + +import static org.folio.circulation.domain.representations.LoanProperties.USAGE_STATUS_HELD; + +public class HoldByBarcodeResource extends UsageStatusChangeResource { + + public HoldByBarcodeResource(HttpClient client) { + super("/circulation/hold-by-barcode-for-use-at-location",client); + } + + @Override + public void register(Router router) { + new RouteRegistration(rootPath, router).create(this::markHeld); + } + + private void markHeld(RoutingContext routingContext) { + final WebContext webContext = new WebContext(routingContext); + final Clients clients = Clients.create(webContext, client); + final OkapiPermissions okapiPermissions = OkapiPermissions.from(webContext.getHeaders()); + final CirculationErrorHandler errorHandler = new OverridingErrorHandler(okapiPermissions); + final var itemRepository = new ItemRepository(clients); + final var userRepository = new UserRepository(clients); + final var loanRepository = new LoanRepository(clients, itemRepository, userRepository); + final EventPublisher eventPublisher = new EventPublisher(routingContext); + + JsonObject bodyAsJson = routingContext.body().asJsonObject(); + + findLoan(bodyAsJson, loanRepository, itemRepository, userRepository, errorHandler) + .thenApply(loanResult -> loanResult.map(loan -> loan.changeStatusOfUsageAtLocation(USAGE_STATUS_HELD))) + .thenApply(loanResult -> loanResult.map(loan -> loan.withAction(LoanAction.HELD_FOR_USE_AT_LOCATION))) + .thenComposeAsync(loanResult -> loanResult.after( + loan -> eventPublisher.publishUsageAtLocationEvent(loan, LogEventType.HELD_FOR_USE_AT_LOCATION))) + .thenComposeAsync(loanResult -> loanRepository.updateLoan(loanResult.value())) + .thenApply(loanResult -> loanResult.map(Loan::asJson)) + .thenApply(loanAsJsonResult -> loanAsJsonResult.map(this::toResponse)) + .thenAccept(webContext::writeResultToHttpResponse);; + } + + +} diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java new file mode 100644 index 0000000000..2865d546ee --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java @@ -0,0 +1,58 @@ +package org.folio.circulation.resources.foruseatlocation; + +import io.vertx.core.http.HttpClient; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import org.folio.circulation.domain.Loan; +import org.folio.circulation.domain.LoanAction; +import org.folio.circulation.domain.representations.logs.LogEventType; +import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; +import org.folio.circulation.infrastructure.storage.loans.LoanRepository; +import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.resources.handlers.error.CirculationErrorHandler; +import org.folio.circulation.resources.handlers.error.OverridingErrorHandler; +import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.support.Clients; +import org.folio.circulation.support.RouteRegistration; +import org.folio.circulation.support.http.OkapiPermissions; +import org.folio.circulation.support.http.server.WebContext; + +import static org.folio.circulation.domain.representations.LoanProperties.USAGE_STATUS_IN_USE; + +public class PickUpByBarcodeResource extends UsageStatusChangeResource { + + public PickUpByBarcodeResource(HttpClient client) { + super("/circulation/pickup-by-barcode-for-use-at-location",client); + } + + @Override + public void register(Router router) { + new RouteRegistration(rootPath, router).create(this::markInUse); + } + + private void markInUse(RoutingContext routingContext) { + final WebContext webContext = new WebContext(routingContext); + final Clients clients = Clients.create(webContext, client); + final OkapiPermissions okapiPermissions = OkapiPermissions.from(webContext.getHeaders()); + final CirculationErrorHandler errorHandler = new OverridingErrorHandler(okapiPermissions); + final var itemRepository = new ItemRepository(clients); + final var userRepository = new UserRepository(clients); + final var loanRepository = new LoanRepository(clients, itemRepository, userRepository); + final EventPublisher eventPublisher = new EventPublisher(routingContext); + + JsonObject bodyAsJson = routingContext.body().asJsonObject(); + + findLoan(bodyAsJson, loanRepository, itemRepository, userRepository, errorHandler) + .thenApply(loanResult -> loanResult.map(loan -> loan.changeStatusOfUsageAtLocation(USAGE_STATUS_IN_USE))) + .thenApply(loanResult -> loanResult.map(loan -> loan.withAction(LoanAction.PICKED_UP_FOR_USE_AT_LOCATION))) + .thenComposeAsync(loanResult -> loanRepository.updateLoan(loanResult.value())) + .thenComposeAsync(loanResult -> loanResult.after( + loan -> eventPublisher.publishUsageAtLocationEvent(loan, LogEventType.PICKED_UP_FOR_USE_AT_LOCATION))) + .thenApply(loanResult -> loanResult.map(Loan::asJson)) + .thenApply(jsonResult -> jsonResult.map(this::toResponse)) + .thenAccept(webContext::writeResultToHttpResponse);; + } + + +} diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/UsageStatusChangeResource.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/UsageStatusChangeResource.java new file mode 100644 index 0000000000..b25fa27260 --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/UsageStatusChangeResource.java @@ -0,0 +1,50 @@ +package org.folio.circulation.resources.foruseatlocation; + +import io.vertx.core.http.HttpClient; +import io.vertx.core.json.JsonObject; +import org.folio.circulation.domain.Loan; +import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; +import org.folio.circulation.infrastructure.storage.loans.LoanRepository; +import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.resources.Resource; +import org.folio.circulation.resources.handlers.error.CirculationErrorHandler; +import org.folio.circulation.storage.SingleOpenLoanByUserAndItemBarcodeFinder; +import org.folio.circulation.support.http.server.HttpResponse; +import org.folio.circulation.support.http.server.JsonHttpResponse; +import org.folio.circulation.support.results.Result; + +import java.util.concurrent.CompletableFuture; + +import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FIND_SINGLE_OPEN_LOAN; +import static org.folio.circulation.resources.foruseatlocation.ChangeUsageStatusByBarcodeRequest.usageStatusChangeRequestFrom; + +public abstract class UsageStatusChangeResource extends Resource { + + protected final String rootPath; + + public UsageStatusChangeResource(String rootPath, HttpClient client) { + super(client); + this.rootPath = rootPath; + } + + protected CompletableFuture> findLoan(JsonObject request, + LoanRepository loanRepository, ItemRepository itemRepository, UserRepository userRepository, + CirculationErrorHandler errorHandler) { + + final SingleOpenLoanByUserAndItemBarcodeFinder finder + = new SingleOpenLoanByUserAndItemBarcodeFinder(loanRepository, + itemRepository, userRepository); + + return usageStatusChangeRequestFrom(request).after(shelfRequest -> + finder.findLoan(shelfRequest.getItemBarcode(), shelfRequest.getUserBarcode()) + .thenApply(r -> errorHandler.handleValidationResult(r, FAILED_TO_FIND_SINGLE_OPEN_LOAN, + (Loan) null))); + } + + protected HttpResponse toResponse(JsonObject body) { + return JsonHttpResponse.ok(body, + String.format("/circulation/loans/%s", body.getString("id"))); + } + + +} diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java index 3a34524b04..e1060e58a2 100644 --- a/src/main/java/org/folio/circulation/services/EventPublisher.java +++ b/src/main/java/org/folio/circulation/services/EventPublisher.java @@ -377,6 +377,11 @@ public CompletableFuture> publishNoticeLogEvent(NoticeLogContext no return publishLogRecord(noticeLogContext.withDate(getZonedDateTime()).asJson(), eventType); } + public CompletableFuture> publishUsageAtLocationEvent(Loan loan, LogEventType eventType) { + return publishLogRecord((LoanLogContext.from(loan)).asJson(), eventType) + .thenApply(r -> succeeded(loan)); + } + public CompletableFuture> publishNoticeErrorLogEvent( NoticeLogContext noticeLogContext, HttpFailure error) { From cc3ceac727999f1c31c9e943e2d1786d9bbb0a44 Mon Sep 17 00:00:00 2001 From: nielserik Date: Mon, 19 May 2025 09:12:56 +0200 Subject: [PATCH 2/4] CIRC-2408 use-at-location (reading room) loans wip - new properties in loan policy - new properties in loan - two new APIs to switch between held and in-use - new held/in-use circulation log event types --- ...at-location-usage-status-change-error.json | 13 ++ ramls/for-use-at-location-shelf.raml | 62 +++++++ ramls/loan.json | 20 +++ ramls/pickup-for-use-at-location-request.json | 19 +++ ramls/put-on-hold-for-use-at-location.json | 14 ++ .../HoldByBarcodeRequest.java | 31 ++++ .../HoldByBarcodeResource.java | 40 ++++- .../PickUpByBarcodeResource.java | 36 +++- ...quest.java => PickupByBarcodeRequest.java} | 8 +- .../UsageStatusChangeResource.java | 50 ------ ...gleOpenLoanByUserAndItemBarcodeFinder.java | 1 - .../api/loans/LoansForUseAtLocationTests.java | 154 ++++++++++++++++++ src/test/java/api/support/APITests.java | 5 + .../builders/HoldByBarcodeRequestBuilder.java | 19 +++ .../support/builders/LoanPolicyBuilder.java | 6 +- .../PickupByBarcodeRequestBuilder.java | 23 +++ .../fixtures/ForUseAtLocationHoldFixture.java | 22 +++ .../ForUseAtLocationPickupFixture.java | 21 +++ .../java/api/support/http/InterfaceUrls.java | 8 + src/test/resources/storage-loan-7-3.json | 20 +++ 20 files changed, 512 insertions(+), 60 deletions(-) create mode 100644 ramls/examples/at-location-usage-status-change-error.json create mode 100644 ramls/for-use-at-location-shelf.raml create mode 100644 ramls/pickup-for-use-at-location-request.json create mode 100644 ramls/put-on-hold-for-use-at-location.json create mode 100644 src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeRequest.java rename src/main/java/org/folio/circulation/resources/foruseatlocation/{ChangeUsageStatusByBarcodeRequest.java => PickupByBarcodeRequest.java} (83%) delete mode 100644 src/main/java/org/folio/circulation/resources/foruseatlocation/UsageStatusChangeResource.java create mode 100644 src/test/java/api/loans/LoansForUseAtLocationTests.java create mode 100644 src/test/java/api/support/builders/HoldByBarcodeRequestBuilder.java create mode 100644 src/test/java/api/support/builders/PickupByBarcodeRequestBuilder.java create mode 100644 src/test/java/api/support/fixtures/ForUseAtLocationHoldFixture.java create mode 100644 src/test/java/api/support/fixtures/ForUseAtLocationPickupFixture.java diff --git a/ramls/examples/at-location-usage-status-change-error.json b/ramls/examples/at-location-usage-status-change-error.json new file mode 100644 index 0000000000..97909e0fa9 --- /dev/null +++ b/ramls/examples/at-location-usage-status-change-error.json @@ -0,0 +1,13 @@ +{ + "errors": [ + { + "message": "TBD", + "parameters": [ + { + "key": "itemId", + "value": "91719676-e7b5-4f83-bdab-cb70dd10c1e3" + } + ] + } + ] +} diff --git a/ramls/for-use-at-location-shelf.raml b/ramls/for-use-at-location-shelf.raml new file mode 100644 index 0000000000..0598341e9f --- /dev/null +++ b/ramls/for-use-at-location-shelf.raml @@ -0,0 +1,62 @@ +#%RAML 1.0 +title: Change usage status of items to be used at location (ie reading room) +version: v0.1 +protocols: [ HTTP, HTTPS ] +baseUri: http://localhost:9130 + +documentation: + - title: API for changing usage status of items used at location + content: Change usage status API + +types: + errors: !include raml-util/schemas/errors.schema + +traits: + validate: !include raml-util/traits/validation.raml + +/circulation: + /hold-by-barcode-for-use-at-location: + post: + is: [validate] + body: + application/json: + type: !include put-on-hold-for-use-at-location-request.json + responses: + 200: + description: "The at-location usage status of the loaned item set to held" + 422: + description: "Unable to change the usage status for the loan" + body: + application/json: + type: errors + example: !include examples/at-location-usage-status-change-error.json + 404: + description: "The loan is not found" + 500: + description: "Internal server error, e.g. due to misconfiguration" + body: + text/plain: + example: "Internal server error, contact administrator" + + /pickup-by-barcode-for-use-at-location: + post: + is: [validate] + body: + application/json: + type: !include pickup-for-use-at-location-request.json + responses: + 200: + description: "The at-location usage status of the loaned item set to in-use" + 422: + description: "Unable to change the usage status for the loan" + body: + application/json: + type: errors + example: !include examples/at-location-usage-status-change-error.json + 404: + description: "The loan is not found" + 500: + description: "Internal server error, e.g. due to misconfiguration" + body: + text/plain: + example: "Internal server error, contact administrator" diff --git a/ramls/loan.json b/ramls/loan.json index 05e92a46c8..96387df488 100644 --- a/ramls/loan.json +++ b/ramls/loan.json @@ -281,6 +281,26 @@ } } }, + "forUseAtLocation": { + "description": "Status of loan/item that is to be used in the library", + "type": "object", + "properties": { + "status": { + "description": "Indicates if the item is currently used by or being held for the patron", + "type": "string", + "enum": [ + "In use", + "Held", + "Returned" + ] + }, + "statusDate": { + "description": "Date and time the status was registered", + "type": "string", + "format": "date-time" + } + } + }, "loanDate": { "description": "Date and time when the loan began", "type": "string", diff --git a/ramls/pickup-for-use-at-location-request.json b/ramls/pickup-for-use-at-location-request.json new file mode 100644 index 0000000000..e7dba09be7 --- /dev/null +++ b/ramls/pickup-for-use-at-location-request.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Request to switch status of use at location to 'In use'", + "properties": { + "itemBarcode": { + "description": "Barcode of the item lent to the patron for use at location", + "type": "string" + }, + "userBarcode": { + "description": "Barcode of the user (representing the patron)", + "type": "string" + } + }, + "required": [ + "itemBarcode", + "userBarcode" + ] +} diff --git a/ramls/put-on-hold-for-use-at-location.json b/ramls/put-on-hold-for-use-at-location.json new file mode 100644 index 0000000000..6c46045d5b --- /dev/null +++ b/ramls/put-on-hold-for-use-at-location.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Request to switch status of use at location to 'Held'", + "properties": { + "itemBarcode": { + "description": "Barcode of the item lent to the patron for use at location", + "type": "string" + } + }, + "required": [ + "itemBarcode" + ] +} diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeRequest.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeRequest.java new file mode 100644 index 0000000000..0ed7960f05 --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeRequest.java @@ -0,0 +1,31 @@ +package org.folio.circulation.resources.foruseatlocation; + +import io.vertx.core.json.JsonObject; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.folio.circulation.support.results.Result; + +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.folio.circulation.support.ValidationErrorFailure.failedValidation; +import static org.folio.circulation.support.json.JsonPropertyFetcher.getProperty; +import static org.folio.circulation.support.results.Result.succeeded; + +@Getter +@AllArgsConstructor +public class HoldByBarcodeRequest { + private static final String ITEM_BARCODE = "itemBarcode"; + + private final String itemBarcode; + + static Result holdByBarcodeRequestFrom(JsonObject json) { + final String itemBarcode = getProperty(json, ITEM_BARCODE); + + if (isBlank(itemBarcode)) { + return failedValidation("Request to change at-location-use status must have an item barcode", + ITEM_BARCODE, null); + } + + return succeeded(new HoldByBarcodeRequest(itemBarcode)); + + } +} diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java index 50f0897bf1..f98d670e4b 100644 --- a/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java @@ -10,20 +10,31 @@ import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; import org.folio.circulation.infrastructure.storage.loans.LoanRepository; import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.resources.Resource; import org.folio.circulation.resources.handlers.error.CirculationErrorHandler; import org.folio.circulation.resources.handlers.error.OverridingErrorHandler; import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.storage.ItemByBarcodeInStorageFinder; +import org.folio.circulation.storage.SingleOpenLoanForItemInStorageFinder; import org.folio.circulation.support.Clients; import org.folio.circulation.support.RouteRegistration; import org.folio.circulation.support.http.OkapiPermissions; +import org.folio.circulation.support.http.server.HttpResponse; +import org.folio.circulation.support.http.server.JsonHttpResponse; import org.folio.circulation.support.http.server.WebContext; +import org.folio.circulation.support.results.Result; + +import java.util.concurrent.CompletableFuture; import static org.folio.circulation.domain.representations.LoanProperties.USAGE_STATUS_HELD; +import static org.folio.circulation.resources.foruseatlocation.HoldByBarcodeRequest.holdByBarcodeRequestFrom; +import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FIND_SINGLE_OPEN_LOAN; -public class HoldByBarcodeResource extends UsageStatusChangeResource { +public class HoldByBarcodeResource extends Resource { + private static final String rootPath = "/circulation/hold-by-barcode-for-use-at-location"; public HoldByBarcodeResource(HttpClient client) { - super("/circulation/hold-by-barcode-for-use-at-location",client); + super(client); } @Override @@ -54,5 +65,30 @@ private void markHeld(RoutingContext routingContext) { .thenAccept(webContext::writeResultToHttpResponse);; } + protected CompletableFuture> findLoan(JsonObject request, + LoanRepository loanRepository, + ItemRepository itemRepository, + UserRepository userRepository, + CirculationErrorHandler errorHandler) { + + final ItemByBarcodeInStorageFinder itemFinder = + new ItemByBarcodeInStorageFinder(itemRepository); + + final SingleOpenLoanForItemInStorageFinder loanFinder = + new SingleOpenLoanForItemInStorageFinder(loanRepository, userRepository, false); + + return holdByBarcodeRequestFrom(request) + .after(shelfRequest -> itemFinder.findItemByBarcode(shelfRequest.getItemBarcode())) + .thenCompose(itemResult -> itemResult.after(loanFinder::findSingleOpenLoan) + .thenApply(r -> + errorHandler.handleValidationResult(r, FAILED_TO_FIND_SINGLE_OPEN_LOAN, (Loan) null)) + ); + } + + private HttpResponse toResponse(JsonObject body) { + return JsonHttpResponse.ok(body, + String.format("/circulation/loans/%s", body.getString("id"))); + } + } diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java index 2865d546ee..9ef7fe31b1 100644 --- a/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java @@ -10,20 +10,31 @@ import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; import org.folio.circulation.infrastructure.storage.loans.LoanRepository; import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.resources.Resource; import org.folio.circulation.resources.handlers.error.CirculationErrorHandler; import org.folio.circulation.resources.handlers.error.OverridingErrorHandler; import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.storage.SingleOpenLoanByUserAndItemBarcodeFinder; import org.folio.circulation.support.Clients; import org.folio.circulation.support.RouteRegistration; import org.folio.circulation.support.http.OkapiPermissions; +import org.folio.circulation.support.http.server.HttpResponse; +import org.folio.circulation.support.http.server.JsonHttpResponse; import org.folio.circulation.support.http.server.WebContext; +import org.folio.circulation.support.results.Result; + +import java.util.concurrent.CompletableFuture; import static org.folio.circulation.domain.representations.LoanProperties.USAGE_STATUS_IN_USE; +import static org.folio.circulation.resources.foruseatlocation.PickupByBarcodeRequest.pickupByBarcodeRequestFrom; +import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FIND_SINGLE_OPEN_LOAN; + +public class PickUpByBarcodeResource extends Resource { -public class PickUpByBarcodeResource extends UsageStatusChangeResource { + private static final String rootPath = "/circulation/pickup-by-barcode-for-use-at-location"; public PickUpByBarcodeResource(HttpClient client) { - super("/circulation/pickup-by-barcode-for-use-at-location",client); + super(client); } @Override @@ -54,5 +65,26 @@ private void markInUse(RoutingContext routingContext) { .thenAccept(webContext::writeResultToHttpResponse);; } + protected CompletableFuture> findLoan(JsonObject request, + LoanRepository loanRepository, + ItemRepository itemRepository, + UserRepository userRepository, + CirculationErrorHandler errorHandler) { + + final SingleOpenLoanByUserAndItemBarcodeFinder loanFinder + = new SingleOpenLoanByUserAndItemBarcodeFinder(loanRepository, + itemRepository, userRepository); + + return pickupByBarcodeRequestFrom(request) + .after(shelfRequest -> loanFinder.findLoan(shelfRequest.getItemBarcode(), shelfRequest.getUserBarcode()) + .thenApply(r -> errorHandler.handleValidationResult(r, FAILED_TO_FIND_SINGLE_OPEN_LOAN, + (Loan) null))); + } + + private HttpResponse toResponse(JsonObject body) { + return JsonHttpResponse.ok(body, + String.format("/circulation/loans/%s", body.getString("id"))); + } + } diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/ChangeUsageStatusByBarcodeRequest.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickupByBarcodeRequest.java similarity index 83% rename from src/main/java/org/folio/circulation/resources/foruseatlocation/ChangeUsageStatusByBarcodeRequest.java rename to src/main/java/org/folio/circulation/resources/foruseatlocation/PickupByBarcodeRequest.java index 3d4ec45a83..40fd60f8bd 100644 --- a/src/main/java/org/folio/circulation/resources/foruseatlocation/ChangeUsageStatusByBarcodeRequest.java +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickupByBarcodeRequest.java @@ -12,14 +12,14 @@ @Getter @AllArgsConstructor -public class ChangeUsageStatusByBarcodeRequest { +public class PickupByBarcodeRequest { private static final String ITEM_BARCODE = "itemBarcode"; private static final String USER_BARCODE = "userBarcode"; private final String itemBarcode; private final String userBarcode; - static Result usageStatusChangeRequestFrom(JsonObject json) { + static Result pickupByBarcodeRequestFrom(JsonObject json) { final String itemBarcode = getProperty(json, ITEM_BARCODE); if (isBlank(itemBarcode)) { @@ -34,7 +34,7 @@ static Result usageStatusChangeRequestFrom(Js USER_BARCODE, null); } - return succeeded(new ChangeUsageStatusByBarcodeRequest(itemBarcode, userBarcode)); - + return succeeded(new PickupByBarcodeRequest(itemBarcode, userBarcode)); } } + diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/UsageStatusChangeResource.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/UsageStatusChangeResource.java deleted file mode 100644 index b25fa27260..0000000000 --- a/src/main/java/org/folio/circulation/resources/foruseatlocation/UsageStatusChangeResource.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.folio.circulation.resources.foruseatlocation; - -import io.vertx.core.http.HttpClient; -import io.vertx.core.json.JsonObject; -import org.folio.circulation.domain.Loan; -import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; -import org.folio.circulation.infrastructure.storage.loans.LoanRepository; -import org.folio.circulation.infrastructure.storage.users.UserRepository; -import org.folio.circulation.resources.Resource; -import org.folio.circulation.resources.handlers.error.CirculationErrorHandler; -import org.folio.circulation.storage.SingleOpenLoanByUserAndItemBarcodeFinder; -import org.folio.circulation.support.http.server.HttpResponse; -import org.folio.circulation.support.http.server.JsonHttpResponse; -import org.folio.circulation.support.results.Result; - -import java.util.concurrent.CompletableFuture; - -import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FIND_SINGLE_OPEN_LOAN; -import static org.folio.circulation.resources.foruseatlocation.ChangeUsageStatusByBarcodeRequest.usageStatusChangeRequestFrom; - -public abstract class UsageStatusChangeResource extends Resource { - - protected final String rootPath; - - public UsageStatusChangeResource(String rootPath, HttpClient client) { - super(client); - this.rootPath = rootPath; - } - - protected CompletableFuture> findLoan(JsonObject request, - LoanRepository loanRepository, ItemRepository itemRepository, UserRepository userRepository, - CirculationErrorHandler errorHandler) { - - final SingleOpenLoanByUserAndItemBarcodeFinder finder - = new SingleOpenLoanByUserAndItemBarcodeFinder(loanRepository, - itemRepository, userRepository); - - return usageStatusChangeRequestFrom(request).after(shelfRequest -> - finder.findLoan(shelfRequest.getItemBarcode(), shelfRequest.getUserBarcode()) - .thenApply(r -> errorHandler.handleValidationResult(r, FAILED_TO_FIND_SINGLE_OPEN_LOAN, - (Loan) null))); - } - - protected HttpResponse toResponse(JsonObject body) { - return JsonHttpResponse.ok(body, - String.format("/circulation/loans/%s", body.getString("id"))); - } - - -} diff --git a/src/main/java/org/folio/circulation/storage/SingleOpenLoanByUserAndItemBarcodeFinder.java b/src/main/java/org/folio/circulation/storage/SingleOpenLoanByUserAndItemBarcodeFinder.java index c2792a1779..8837693ff5 100644 --- a/src/main/java/org/folio/circulation/storage/SingleOpenLoanByUserAndItemBarcodeFinder.java +++ b/src/main/java/org/folio/circulation/storage/SingleOpenLoanByUserAndItemBarcodeFinder.java @@ -49,7 +49,6 @@ public CompletableFuture> findLoan( private Function>> refuseWhenUserDoesNotMatch( String userBarcode) { - return loan -> refuseWhenUserDoesNotMatch(loan, userBarcode); } diff --git a/src/test/java/api/loans/LoansForUseAtLocationTests.java b/src/test/java/api/loans/LoansForUseAtLocationTests.java new file mode 100644 index 0000000000..76249308ad --- /dev/null +++ b/src/test/java/api/loans/LoansForUseAtLocationTests.java @@ -0,0 +1,154 @@ +package api.loans; + +import api.support.APITests; +import api.support.builders.*; +import api.support.http.IndividualResource; +import api.support.http.ItemResource; +import api.support.http.UserResource; +import io.vertx.core.json.JsonObject; +import org.folio.circulation.domain.policy.Period; +import org.folio.circulation.support.http.client.Response; +import org.hamcrest.core.Is; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.UUID; + +import static api.support.fixtures.ItemExamples.basedUponSmallAngryPlanet; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +public class LoansForUseAtLocationTests extends APITests { + private ItemResource item; + private UserResource borrower; + + @BeforeEach + void beforeEach() { + + HoldingBuilder holdingsBuilder = itemsFixture.applyCallNumberHoldings( + "CN", + "Prefix", + "Suffix", + Collections.singletonList("CopyNumbers")); + + final UUID servicePointId = servicePointsFixture.cd1().getId(); + + final IndividualResource homeLocation = locationsFixture.basedUponExampleLocation( + item -> item.withPrimaryServicePoint(servicePointId)); + + ItemBuilder itemBuilder = basedUponSmallAngryPlanet( + materialTypesFixture.book().getId(), + loanTypesFixture.canCirculate().getId()) + .withPermanentLocation(homeLocation); + + item = itemsFixture.basedUponSmallAngryPlanet(itemBuilder, holdingsBuilder); + + borrower = usersFixture.steve(); + } + + @Test + void willSetAtLocationUsageStatusToInUseOnCheckout() { + final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() + .withName("Reading room loans") + .withDescription("Policy for items to be used at location") + .rolling(Period.days(30)) + .withForUseAtLocation(true); + + use(forUseAtLocationPolicyBuilder); + + final IndividualResource response = checkOutFixture.checkOutByBarcode( + new CheckOutByBarcodeRequestBuilder() + .forItem(item) + .to(borrower) + .at(servicePointsFixture.cd1())); + + JsonObject loan = loansFixture.getLoanById(response.getId()).getJson(); + JsonObject forUseAtLocation = loan.getJsonObject("forUseAtLocation"); + assertThat("loan.forUseAtLocation", + forUseAtLocation, notNullValue()); + assertThat("loan.forUseAtLocation.status", + forUseAtLocation.getString("status"), Is.is("In use")); + } + + @Test + void willMarkItemHeldByBarcode() { + final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() + .withName("Reading room loans") + .withDescription("Policy for items to be used at location") + .rolling(Period.days(30)) + .withForUseAtLocation(true); + + use(forUseAtLocationPolicyBuilder); + + checkOutFixture.checkOutByBarcode( + new CheckOutByBarcodeRequestBuilder() + .forItem(item) + .to(borrower) + .at(servicePointsFixture.cd1())); + + Response holdResponse = holdForUseAtLocationFixture.holdForUseAtLocation( + new HoldByBarcodeRequestBuilder(item.getBarcode())); + + JsonObject forUseAtLocation = holdResponse.getJson().getJsonObject("forUseAtLocation"); + assertThat("loan.forUseAtLocation", + forUseAtLocation, notNullValue()); + assertThat("loan.forUseAtLocation.status", + forUseAtLocation.getString("status"), Is.is("Held")); + } + + @Test + void willMarkItemInUseByBarcode() { + final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() + .withName("Reading room loans") + .withDescription("Policy for items to be used at location") + .rolling(Period.days(30)) + .withForUseAtLocation(true); + + use(forUseAtLocationPolicyBuilder); + + checkOutFixture.checkOutByBarcode( + new CheckOutByBarcodeRequestBuilder() + .forItem(item) + .to(borrower) + .at(servicePointsFixture.cd1())); + + Response pickupResponse = pickupForUseAtLocationFixture.pickupForUseAtLocation( + new PickupByBarcodeRequestBuilder(item.getBarcode(), borrower.getBarcode())); + + JsonObject forUseAtLocation = pickupResponse.getJson().getJsonObject("forUseAtLocation"); + assertThat("loan.forUseAtLocation", + forUseAtLocation, notNullValue()); + assertThat("loan.forUseAtLocation.status", + forUseAtLocation.getString("status"), Is.is("In use")); + } + + @Test + void willSetAtLocationUsageStatusToReturnedOnCheckIn() { + final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() + .withName("Reading room loans") + .withDescription("Policy for items to be used at location") + .rolling(Period.days(30)) + .withForUseAtLocation(true); + + use(forUseAtLocationPolicyBuilder); + + checkOutFixture.checkOutByBarcode( + new CheckOutByBarcodeRequestBuilder() + .forItem(item) + .to(borrower) + .at(servicePointsFixture.cd1())); + + final IndividualResource response = checkInFixture.checkInByBarcode(item); + + JsonObject loan = loansFixture.getLoanById( + UUID.fromString(response.getJson().getJsonObject("loan").getString("id"))) + .getJson(); + JsonObject forUseAtLocation = loan.getJsonObject("forUseAtLocation"); + assertThat("loan.forUseAtLocation", + forUseAtLocation, notNullValue()); + assertThat("loan.forUseAtLocation.status", + forUseAtLocation.getString("status"), Is.is("Returned")); + } + +} diff --git a/src/test/java/api/support/APITests.java b/src/test/java/api/support/APITests.java index 88b1c0915a..5ef8e7a5fc 100644 --- a/src/test/java/api/support/APITests.java +++ b/src/test/java/api/support/APITests.java @@ -56,6 +56,8 @@ import api.support.fixtures.FeeFineAccountFixture; import api.support.fixtures.FeeFineOwnerFixture; import api.support.fixtures.FeeFineTypeFixture; +import api.support.fixtures.ForUseAtLocationHoldFixture; +import api.support.fixtures.ForUseAtLocationPickupFixture; import api.support.fixtures.HoldingsFixture; import api.support.fixtures.IdentifierTypesFixture; import api.support.fixtures.InstancesFixture; @@ -307,6 +309,9 @@ public abstract class APITests { protected final ConfigurationsFixture configurationsFixture = new ConfigurationsFixture(configClient); protected final SearchInstanceFixture searchFixture = new SearchInstanceFixture(); + protected final ForUseAtLocationHoldFixture holdForUseAtLocationFixture = new ForUseAtLocationHoldFixture(); + protected final ForUseAtLocationPickupFixture pickupForUseAtLocationFixture = new ForUseAtLocationPickupFixture(); + protected APITests() { this(true, false); } diff --git a/src/test/java/api/support/builders/HoldByBarcodeRequestBuilder.java b/src/test/java/api/support/builders/HoldByBarcodeRequestBuilder.java new file mode 100644 index 0000000000..3e72fdd98d --- /dev/null +++ b/src/test/java/api/support/builders/HoldByBarcodeRequestBuilder.java @@ -0,0 +1,19 @@ +package api.support.builders; + +import io.vertx.core.json.JsonObject; + +public class HoldByBarcodeRequestBuilder extends JsonBuilder implements Builder { + private final String itemBarcode; + + public HoldByBarcodeRequestBuilder(String itemBarcode) { + this.itemBarcode = itemBarcode; + } + @Override + public JsonObject create() { + final JsonObject request = new JsonObject(); + + put(request, "itemBarcode", this.itemBarcode); + return request; + } +} + diff --git a/src/test/java/api/support/builders/LoanPolicyBuilder.java b/src/test/java/api/support/builders/LoanPolicyBuilder.java index 7fb031582a..0eb352ad87 100644 --- a/src/test/java/api/support/builders/LoanPolicyBuilder.java +++ b/src/test/java/api/support/builders/LoanPolicyBuilder.java @@ -40,6 +40,7 @@ public class LoanPolicyBuilder extends JsonBuilder implements Builder { private final Period alternateCheckoutLoanPeriod; private final Integer itemLimit; private final Period gracePeriod; + private final boolean forUseAtLocation; public LoanPolicyBuilder() { this(UUID.randomUUID(), @@ -65,7 +66,8 @@ public LoanPolicyBuilder() { null, null, null, - null + null, + false ); } @@ -88,6 +90,7 @@ public JsonObject create() { put(loansPolicy, "profileId", loansProfile); put(loansPolicy, "itemLimit", itemLimit); putIfNotNull(loansPolicy, "gracePeriod", gracePeriod, Period::asJson); + put(loansPolicy, "forUseAtLocation", forUseAtLocation); //TODO: Replace with sub-builders if(Objects.equals(loansProfile, "Rolling")) { @@ -289,4 +292,5 @@ public LoanPolicyBuilder withHolds(Period checkoutPeriod, return withHolds(json); } + } diff --git a/src/test/java/api/support/builders/PickupByBarcodeRequestBuilder.java b/src/test/java/api/support/builders/PickupByBarcodeRequestBuilder.java new file mode 100644 index 0000000000..bbaaa23c08 --- /dev/null +++ b/src/test/java/api/support/builders/PickupByBarcodeRequestBuilder.java @@ -0,0 +1,23 @@ +package api.support.builders; + +import io.vertx.core.json.JsonObject; + +public class PickupByBarcodeRequestBuilder extends JsonBuilder implements Builder { + private final String itemBarcode; + private final String userBarcode; + + public PickupByBarcodeRequestBuilder( + String itemBarcode, + String userBarcode) { + this.itemBarcode = itemBarcode; + this.userBarcode = userBarcode; + } + @Override + public JsonObject create() { + final JsonObject request = new JsonObject(); + + put(request, "itemBarcode", this.itemBarcode); + put(request, "userBarcode", this.userBarcode); + return request; + } +} diff --git a/src/test/java/api/support/fixtures/ForUseAtLocationHoldFixture.java b/src/test/java/api/support/fixtures/ForUseAtLocationHoldFixture.java new file mode 100644 index 0000000000..ca7e9ce719 --- /dev/null +++ b/src/test/java/api/support/fixtures/ForUseAtLocationHoldFixture.java @@ -0,0 +1,22 @@ +package api.support.fixtures; + +import api.support.RestAssuredClient; +import api.support.builders.HoldByBarcodeRequestBuilder; +import org.folio.circulation.support.http.client.Response; + +import static api.support.APITestContext.getOkapiHeadersFromContext; +import static api.support.http.InterfaceUrls.holdForUseAtLocationUrl; + +public class ForUseAtLocationHoldFixture { + private final RestAssuredClient restAssuredClient; + + public ForUseAtLocationHoldFixture() { + restAssuredClient = new RestAssuredClient(getOkapiHeadersFromContext()); + + } + + public Response holdForUseAtLocation(HoldByBarcodeRequestBuilder builder) { + return restAssuredClient.post(builder.create(), holdForUseAtLocationUrl(), 200, + "hold-for-use-at-location-by-barcode-request"); + } +} diff --git a/src/test/java/api/support/fixtures/ForUseAtLocationPickupFixture.java b/src/test/java/api/support/fixtures/ForUseAtLocationPickupFixture.java new file mode 100644 index 0000000000..80d77c100e --- /dev/null +++ b/src/test/java/api/support/fixtures/ForUseAtLocationPickupFixture.java @@ -0,0 +1,21 @@ +package api.support.fixtures; + +import api.support.RestAssuredClient; +import api.support.builders.PickupByBarcodeRequestBuilder; +import org.folio.circulation.support.http.client.Response; + +import static api.support.APITestContext.getOkapiHeadersFromContext; +import static api.support.http.InterfaceUrls.pickupForUseAtLocationUrl; + +public class ForUseAtLocationPickupFixture { + private final RestAssuredClient restAssuredClient; + + public ForUseAtLocationPickupFixture() { + restAssuredClient = new RestAssuredClient(getOkapiHeadersFromContext()); + } + + public Response pickupForUseAtLocation(PickupByBarcodeRequestBuilder builder) { + return restAssuredClient.post(builder.create(), pickupForUseAtLocationUrl(), 200, + "pickup-for-use-at-location-by-barcode-request"); + } +} diff --git a/src/test/java/api/support/http/InterfaceUrls.java b/src/test/java/api/support/http/InterfaceUrls.java index 2b4560793e..5abfcb039f 100644 --- a/src/test/java/api/support/http/InterfaceUrls.java +++ b/src/test/java/api/support/http/InterfaceUrls.java @@ -162,6 +162,14 @@ public static URL renewByIdUrl() { return circulationModuleUrl("/circulation/renew-by-id"); } + public static URL holdForUseAtLocationUrl() { + return circulationModuleUrl("/circulation/hold-by-barcode-for-use-at-location"); + } + + public static URL pickupForUseAtLocationUrl() { + return circulationModuleUrl("/circulation/pickup-by-barcode-for-use-at-location"); + } + public static URL loansUrl() { return loansUrl(""); } diff --git a/src/test/resources/storage-loan-7-3.json b/src/test/resources/storage-loan-7-3.json index 136137f8af..5d419de379 100644 --- a/src/test/resources/storage-loan-7-3.json +++ b/src/test/resources/storage-loan-7-3.json @@ -37,6 +37,26 @@ }, "additionalProperties": false }, + "forUseAtLocation": { + "description": "Status of loan/item that is to be used in the library", + "type": "object", + "properties": { + "status": { + "description": "Indicates if the item is currently used by or being held for the patron", + "type": "string", + "enum": [ + "In use", + "Held", + "Returned" + ] + }, + "statusDate": { + "description": "Date and time the status was registered", + "type": "string", + "format": "date-time" + } + } + }, "loanDate": { "description": "Date time when the loan began (typically represented according to rfc3339 section-5.6. Has not had the date-time format validation applied as was not supported at point of introduction and would now be a breaking change)", "type": "string" From 99e0ccff978d8017ce47b3a56baba7d6c9a0dba2 Mon Sep 17 00:00:00 2001 From: nielserik Date: Tue, 20 May 2025 11:22:24 +0200 Subject: [PATCH 3/4] CIRC-2408 error responses for hold and pickup --- .../HoldByBarcodeRequest.java | 4 +- .../HoldByBarcodeResource.java | 43 ++++++--- .../PickUpByBarcodeResource.java | 52 +++++++---- .../PickupByBarcodeRequest.java | 6 +- .../api/loans/LoansForUseAtLocationTests.java | 88 +++++++++++++++++++ .../fixtures/ForUseAtLocationHoldFixture.java | 7 +- .../ForUseAtLocationPickupFixture.java | 6 ++ 7 files changed, 173 insertions(+), 33 deletions(-) diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeRequest.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeRequest.java index 0ed7960f05..1d39bab74e 100644 --- a/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeRequest.java +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeRequest.java @@ -17,11 +17,11 @@ public class HoldByBarcodeRequest { private final String itemBarcode; - static Result holdByBarcodeRequestFrom(JsonObject json) { + static Result buildRequestFrom(JsonObject json) { final String itemBarcode = getProperty(json, ITEM_BARCODE); if (isBlank(itemBarcode)) { - return failedValidation("Request to change at-location-use status must have an item barcode", + return failedValidation("Request to put item on hold shelf must have an item barcode", ITEM_BARCODE, null); } diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java index f98d670e4b..e124ed21ba 100644 --- a/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/HoldByBarcodeResource.java @@ -16,7 +16,9 @@ import org.folio.circulation.services.EventPublisher; import org.folio.circulation.storage.ItemByBarcodeInStorageFinder; import org.folio.circulation.storage.SingleOpenLoanForItemInStorageFinder; +import org.folio.circulation.support.BadRequestFailure; import org.folio.circulation.support.Clients; +import org.folio.circulation.support.HttpFailure; import org.folio.circulation.support.RouteRegistration; import org.folio.circulation.support.http.OkapiPermissions; import org.folio.circulation.support.http.server.HttpResponse; @@ -25,9 +27,10 @@ import org.folio.circulation.support.results.Result; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; +import static java.lang.String.format; import static org.folio.circulation.domain.representations.LoanProperties.USAGE_STATUS_HELD; -import static org.folio.circulation.resources.foruseatlocation.HoldByBarcodeRequest.holdByBarcodeRequestFrom; import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FIND_SINGLE_OPEN_LOAN; public class HoldByBarcodeResource extends Resource { @@ -52,20 +55,24 @@ private void markHeld(RoutingContext routingContext) { final var loanRepository = new LoanRepository(clients, itemRepository, userRepository); final EventPublisher eventPublisher = new EventPublisher(routingContext); - JsonObject bodyAsJson = routingContext.body().asJsonObject(); + JsonObject requestBodyAsJson = routingContext.body().asJsonObject(); + Result requestResult = HoldByBarcodeRequest.buildRequestFrom(requestBodyAsJson); - findLoan(bodyAsJson, loanRepository, itemRepository, userRepository, errorHandler) + requestResult + .after(request -> findLoan(request, loanRepository, itemRepository, userRepository, errorHandler)) + .thenApply(loan -> failWhenOpenLoanNotFoundForItem(loan, requestResult.value())) .thenApply(loanResult -> loanResult.map(loan -> loan.changeStatusOfUsageAtLocation(USAGE_STATUS_HELD))) .thenApply(loanResult -> loanResult.map(loan -> loan.withAction(LoanAction.HELD_FOR_USE_AT_LOCATION))) - .thenComposeAsync(loanResult -> loanResult.after( + .thenCompose(loanResult -> loanResult.after( + loan -> loanRepository.updateLoan(loanResult.value()))) + .thenCompose(loanResult -> loanResult.after( loan -> eventPublisher.publishUsageAtLocationEvent(loan, LogEventType.HELD_FOR_USE_AT_LOCATION))) - .thenComposeAsync(loanResult -> loanRepository.updateLoan(loanResult.value())) .thenApply(loanResult -> loanResult.map(Loan::asJson)) .thenApply(loanAsJsonResult -> loanAsJsonResult.map(this::toResponse)) - .thenAccept(webContext::writeResultToHttpResponse);; + .thenAccept(webContext::writeResultToHttpResponse); } - protected CompletableFuture> findLoan(JsonObject request, + protected CompletableFuture> findLoan(HoldByBarcodeRequest request, LoanRepository loanRepository, ItemRepository itemRepository, UserRepository userRepository, @@ -77,17 +84,29 @@ protected CompletableFuture> findLoan(JsonObject request, final SingleOpenLoanForItemInStorageFinder loanFinder = new SingleOpenLoanForItemInStorageFinder(loanRepository, userRepository, false); - return holdByBarcodeRequestFrom(request) - .after(shelfRequest -> itemFinder.findItemByBarcode(shelfRequest.getItemBarcode())) + return itemFinder.findItemByBarcode(request.getItemBarcode()) .thenCompose(itemResult -> itemResult.after(loanFinder::findSingleOpenLoan) - .thenApply(r -> - errorHandler.handleValidationResult(r, FAILED_TO_FIND_SINGLE_OPEN_LOAN, (Loan) null)) + .thenApply(r -> errorHandler.handleValidationResult(r, FAILED_TO_FIND_SINGLE_OPEN_LOAN, (Loan) null)) ); } + private Result failWhenOpenLoanNotFoundForItem (Result loanResult, HoldByBarcodeRequest request) { + return loanResult.failWhen(this::loanIsNull, loan -> noOpenLoanFailure(request).get()); + } + + private Result loanIsNull (Loan loan) { + return Result.succeeded(loan == null); + } + + private static Supplier noOpenLoanFailure(HoldByBarcodeRequest request) { + return () -> new BadRequestFailure( + format("No open loan found for the item barcode (%s)", request.getItemBarcode()) + ); + } + private HttpResponse toResponse(JsonObject body) { return JsonHttpResponse.ok(body, - String.format("/circulation/loans/%s", body.getString("id"))); + format("/circulation/loans/%s", body.getString("id"))); } diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java index 9ef7fe31b1..4f521de143 100644 --- a/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickUpByBarcodeResource.java @@ -15,7 +15,9 @@ import org.folio.circulation.resources.handlers.error.OverridingErrorHandler; import org.folio.circulation.services.EventPublisher; import org.folio.circulation.storage.SingleOpenLoanByUserAndItemBarcodeFinder; +import org.folio.circulation.support.BadRequestFailure; import org.folio.circulation.support.Clients; +import org.folio.circulation.support.HttpFailure; import org.folio.circulation.support.RouteRegistration; import org.folio.circulation.support.http.OkapiPermissions; import org.folio.circulation.support.http.server.HttpResponse; @@ -24,9 +26,12 @@ import org.folio.circulation.support.results.Result; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; +import static java.lang.String.format; import static org.folio.circulation.domain.representations.LoanProperties.USAGE_STATUS_IN_USE; -import static org.folio.circulation.resources.foruseatlocation.PickupByBarcodeRequest.pickupByBarcodeRequestFrom; +import static org.folio.circulation.resources.foruseatlocation.PickupByBarcodeRequest.buildRequestFrom; + import static org.folio.circulation.resources.handlers.error.CirculationErrorType.FAILED_TO_FIND_SINGLE_OPEN_LOAN; public class PickUpByBarcodeResource extends Resource { @@ -52,20 +57,25 @@ private void markInUse(RoutingContext routingContext) { final var loanRepository = new LoanRepository(clients, itemRepository, userRepository); final EventPublisher eventPublisher = new EventPublisher(routingContext); - JsonObject bodyAsJson = routingContext.body().asJsonObject(); - - findLoan(bodyAsJson, loanRepository, itemRepository, userRepository, errorHandler) - .thenApply(loanResult -> loanResult.map(loan -> loan.changeStatusOfUsageAtLocation(USAGE_STATUS_IN_USE))) - .thenApply(loanResult -> loanResult.map(loan -> loan.withAction(LoanAction.PICKED_UP_FOR_USE_AT_LOCATION))) - .thenComposeAsync(loanResult -> loanRepository.updateLoan(loanResult.value())) - .thenComposeAsync(loanResult -> loanResult.after( + JsonObject requestBodyAsJson = routingContext.body().asJsonObject(); + Result pickupByBarcodeRequest = buildRequestFrom(requestBodyAsJson); + + pickupByBarcodeRequest + .after(request -> findLoan(request, loanRepository, itemRepository, userRepository, errorHandler)) + .thenApply(loan -> failWhenOpenLoanForItemAndUserNotFound(loan, pickupByBarcodeRequest.value())) + .thenApply(loanResult -> loanResult.map(loan -> + loan.changeStatusOfUsageAtLocation(USAGE_STATUS_IN_USE) + .withAction(LoanAction.PICKED_UP_FOR_USE_AT_LOCATION))) + .thenCompose(loanResult -> loanResult.after( + loan -> loanRepository.updateLoan(loanResult.value()))) + .thenCompose(loanResult -> loanResult.after( loan -> eventPublisher.publishUsageAtLocationEvent(loan, LogEventType.PICKED_UP_FOR_USE_AT_LOCATION))) .thenApply(loanResult -> loanResult.map(Loan::asJson)) .thenApply(jsonResult -> jsonResult.map(this::toResponse)) - .thenAccept(webContext::writeResultToHttpResponse);; + .thenAccept(webContext::writeResultToHttpResponse); } - protected CompletableFuture> findLoan(JsonObject request, + protected CompletableFuture> findLoan(PickupByBarcodeRequest request, LoanRepository loanRepository, ItemRepository itemRepository, UserRepository userRepository, @@ -75,16 +85,28 @@ protected CompletableFuture> findLoan(JsonObject request, = new SingleOpenLoanByUserAndItemBarcodeFinder(loanRepository, itemRepository, userRepository); - return pickupByBarcodeRequestFrom(request) - .after(shelfRequest -> loanFinder.findLoan(shelfRequest.getItemBarcode(), shelfRequest.getUserBarcode()) - .thenApply(r -> errorHandler.handleValidationResult(r, FAILED_TO_FIND_SINGLE_OPEN_LOAN, - (Loan) null))); + return loanFinder.findLoan(request.getItemBarcode(), request.getUserBarcode()) + .thenApply(r -> errorHandler.handleValidationResult(r, FAILED_TO_FIND_SINGLE_OPEN_LOAN, r.value())); } private HttpResponse toResponse(JsonObject body) { return JsonHttpResponse.ok(body, - String.format("/circulation/loans/%s", body.getString("id"))); + format("/circulation/loans/%s", body.getString("id"))); + } + + private Result failWhenOpenLoanForItemAndUserNotFound (Result loanResult, PickupByBarcodeRequest request) { + return loanResult.failWhen(this::loanIsNull, loan -> noOpenLoanFailure(request).get()); } + private Result loanIsNull (Loan loan) { + return Result.succeeded(loan == null); + } + + private static Supplier noOpenLoanFailure(PickupByBarcodeRequest request) { + return () -> new BadRequestFailure( + format("No open loan found for item barcode (%s) and user (%s)", + request.getItemBarcode(), request.getUserBarcode()) + ); + } } diff --git a/src/main/java/org/folio/circulation/resources/foruseatlocation/PickupByBarcodeRequest.java b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickupByBarcodeRequest.java index 40fd60f8bd..0666a72cb4 100644 --- a/src/main/java/org/folio/circulation/resources/foruseatlocation/PickupByBarcodeRequest.java +++ b/src/main/java/org/folio/circulation/resources/foruseatlocation/PickupByBarcodeRequest.java @@ -19,18 +19,18 @@ public class PickupByBarcodeRequest { private final String itemBarcode; private final String userBarcode; - static Result pickupByBarcodeRequestFrom(JsonObject json) { + static Result buildRequestFrom(JsonObject json) { final String itemBarcode = getProperty(json, ITEM_BARCODE); if (isBlank(itemBarcode)) { - return failedValidation("Request to change at-location-use status must have an item barcode", + return failedValidation("Request to pick up from hold shelf must have an item barcode", ITEM_BARCODE, null); } final String userBarcode = getProperty(json, USER_BARCODE); if (isBlank(userBarcode)) { - return failedValidation("Request to change at-location-use status must have a user barcode", + return failedValidation("Request to pick up from hold shelf must have a user barcode", USER_BARCODE, null); } diff --git a/src/test/java/api/loans/LoansForUseAtLocationTests.java b/src/test/java/api/loans/LoansForUseAtLocationTests.java index 76249308ad..8b495b2db1 100644 --- a/src/test/java/api/loans/LoansForUseAtLocationTests.java +++ b/src/test/java/api/loans/LoansForUseAtLocationTests.java @@ -97,6 +97,47 @@ void willMarkItemHeldByBarcode() { forUseAtLocation.getString("status"), Is.is("Held")); } + @Test + void holdWillFailWithDifferentItem() { + final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() + .withName("Reading room loans") + .withDescription("Policy for items to be used at location") + .rolling(Period.days(30)) + .withForUseAtLocation(true); + + use(forUseAtLocationPolicyBuilder); + + checkOutFixture.checkOutByBarcode( + new CheckOutByBarcodeRequestBuilder() + .forItem(item) + .to(borrower) + .at(servicePointsFixture.cd1())); + + holdForUseAtLocationFixture.holdForUseAtLocation( + new HoldByBarcodeRequestBuilder("different-item"), 400); + } + + @Test + void holdWillFailWithIncompleteRequest() { + final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() + .withName("Reading room loans") + .withDescription("Policy for items to be used at location") + .rolling(Period.days(30)) + .withForUseAtLocation(true); + + use(forUseAtLocationPolicyBuilder); + + checkOutFixture.checkOutByBarcode( + new CheckOutByBarcodeRequestBuilder() + .forItem(item) + .to(borrower) + .at(servicePointsFixture.cd1())); + + holdForUseAtLocationFixture.holdForUseAtLocation( + new HoldByBarcodeRequestBuilder(null), 422); + } + + @Test void willMarkItemInUseByBarcode() { final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() @@ -123,6 +164,53 @@ void willMarkItemInUseByBarcode() { forUseAtLocation.getString("status"), Is.is("In use")); } + @Test + void pickupWillFailWithDifferentItemOrDifferentUser() { + final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() + .withName("Reading room loans") + .withDescription("Policy for items to be used at location") + .rolling(Period.days(30)) + .withForUseAtLocation(true); + + use(forUseAtLocationPolicyBuilder); + + checkOutFixture.checkOutByBarcode( + new CheckOutByBarcodeRequestBuilder() + .forItem(item) + .to(borrower) + .at(servicePointsFixture.cd1())); + + pickupForUseAtLocationFixture.pickupForUseAtLocation( + new PickupByBarcodeRequestBuilder("different-item", borrower.getBarcode()), 400); + + pickupForUseAtLocationFixture.pickupForUseAtLocation( + new PickupByBarcodeRequestBuilder(item.getBarcode(), "different-user"), 400); + + } + + @Test + void pickupWillFailWithIncompleteRequestObject() { + final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() + .withName("Reading room loans") + .withDescription("Policy for items to be used at location") + .rolling(Period.days(30)) + .withForUseAtLocation(true); + + use(forUseAtLocationPolicyBuilder); + + checkOutFixture.checkOutByBarcode( + new CheckOutByBarcodeRequestBuilder() + .forItem(item) + .to(borrower) + .at(servicePointsFixture.cd1())); + + pickupForUseAtLocationFixture.pickupForUseAtLocation( + new PickupByBarcodeRequestBuilder(null, borrower.getBarcode()), 422); + + pickupForUseAtLocationFixture.pickupForUseAtLocation( + new PickupByBarcodeRequestBuilder(item.getBarcode(), null), 422); + } + @Test void willSetAtLocationUsageStatusToReturnedOnCheckIn() { final LoanPolicyBuilder forUseAtLocationPolicyBuilder = new LoanPolicyBuilder() diff --git a/src/test/java/api/support/fixtures/ForUseAtLocationHoldFixture.java b/src/test/java/api/support/fixtures/ForUseAtLocationHoldFixture.java index ca7e9ce719..bc0d31112a 100644 --- a/src/test/java/api/support/fixtures/ForUseAtLocationHoldFixture.java +++ b/src/test/java/api/support/fixtures/ForUseAtLocationHoldFixture.java @@ -12,11 +12,16 @@ public class ForUseAtLocationHoldFixture { public ForUseAtLocationHoldFixture() { restAssuredClient = new RestAssuredClient(getOkapiHeadersFromContext()); - } public Response holdForUseAtLocation(HoldByBarcodeRequestBuilder builder) { return restAssuredClient.post(builder.create(), holdForUseAtLocationUrl(), 200, "hold-for-use-at-location-by-barcode-request"); } + + public void holdForUseAtLocation(HoldByBarcodeRequestBuilder builder, int expectedStatusCode) { + restAssuredClient.post(builder.create(), holdForUseAtLocationUrl(), expectedStatusCode, + "hold-for-use-at-location-by-barcode-request"); + } + } diff --git a/src/test/java/api/support/fixtures/ForUseAtLocationPickupFixture.java b/src/test/java/api/support/fixtures/ForUseAtLocationPickupFixture.java index 80d77c100e..2fc5a504b3 100644 --- a/src/test/java/api/support/fixtures/ForUseAtLocationPickupFixture.java +++ b/src/test/java/api/support/fixtures/ForUseAtLocationPickupFixture.java @@ -18,4 +18,10 @@ public Response pickupForUseAtLocation(PickupByBarcodeRequestBuilder builder) { return restAssuredClient.post(builder.create(), pickupForUseAtLocationUrl(), 200, "pickup-for-use-at-location-by-barcode-request"); } + + public void pickupForUseAtLocation(PickupByBarcodeRequestBuilder builder, int expectedStatusCode) { + restAssuredClient.post(builder.create(), pickupForUseAtLocationUrl(), expectedStatusCode, + "pickup-for-use-at-location-by-barcode-request"); + } + } From e7dc416f61f3b8a0d3864694b476314057a737b1 Mon Sep 17 00:00:00 2001 From: nielserik Date: Wed, 21 May 2025 20:27:33 +0200 Subject: [PATCH 4/4] CIRC-2308 fix missing schema in raml --- ramls/for-use-at-location-shelf.raml | 2 +- ramls/hold-for-use-at-location-request.json | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 ramls/hold-for-use-at-location-request.json diff --git a/ramls/for-use-at-location-shelf.raml b/ramls/for-use-at-location-shelf.raml index 0598341e9f..5e1e3f9e3d 100644 --- a/ramls/for-use-at-location-shelf.raml +++ b/ramls/for-use-at-location-shelf.raml @@ -20,7 +20,7 @@ traits: is: [validate] body: application/json: - type: !include put-on-hold-for-use-at-location-request.json + type: !include hold-for-use-at-location-request.json responses: 200: description: "The at-location usage status of the loaned item set to held" diff --git a/ramls/hold-for-use-at-location-request.json b/ramls/hold-for-use-at-location-request.json new file mode 100644 index 0000000000..eef6c19853 --- /dev/null +++ b/ramls/hold-for-use-at-location-request.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Request to switch status of use at location to 'Held'", + "properties": { + "itemBarcode": { + "description": "Barcode of the item being in use at location", + "type": "string" + } + }, + "required": [ + "itemBarcode" + ] +}