diff --git a/src/main/java/org/folio/listener/kafka/KafkaEventListener.java b/src/main/java/org/folio/listener/kafka/KafkaEventListener.java index f6e7b840..64d393fb 100644 --- a/src/main/java/org/folio/listener/kafka/KafkaEventListener.java +++ b/src/main/java/org/folio/listener/kafka/KafkaEventListener.java @@ -5,12 +5,14 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; +import org.folio.domain.dto.Loan; import org.folio.domain.dto.Request; import org.folio.domain.dto.RequestsBatchUpdate; import org.folio.domain.dto.User; import org.folio.domain.dto.UserGroup; import org.folio.exception.KafkaEventDeserializationException; import org.folio.service.KafkaEventHandler; +import org.folio.service.impl.LoanEventHandler; import org.folio.service.impl.RequestBatchUpdateEventHandler; import org.folio.service.impl.RequestEventHandler; import org.folio.service.impl.UserEventHandler; @@ -34,18 +36,20 @@ public class KafkaEventListener { private static final ObjectMapper objectMapper = new ObjectMapper(); private final RequestEventHandler requestEventHandler; + private final LoanEventHandler loanEventHandler; private final UserGroupEventHandler userGroupEventHandler; private final UserEventHandler userEventHandler; private final SystemUserScopedExecutionService systemUserScopedExecutionService; private final RequestBatchUpdateEventHandler requestBatchEventHandler; - public KafkaEventListener(@Autowired RequestEventHandler requestEventHandler, - @Autowired RequestBatchUpdateEventHandler requestBatchEventHandler, - @Autowired SystemUserScopedExecutionService systemUserScopedExecutionService, - @Autowired UserGroupEventHandler userGroupEventHandler, - @Autowired UserEventHandler userEventHandler) { + @Autowired + public KafkaEventListener(RequestEventHandler requestEventHandler, + LoanEventHandler loanEventHandler, RequestBatchUpdateEventHandler requestBatchEventHandler, + SystemUserScopedExecutionService systemUserScopedExecutionService, + UserGroupEventHandler userGroupEventHandler, UserEventHandler userEventHandler) { this.requestEventHandler = requestEventHandler; + this.loanEventHandler = loanEventHandler; this.systemUserScopedExecutionService = systemUserScopedExecutionService; this.userGroupEventHandler = userGroupEventHandler; this.requestBatchEventHandler = requestBatchEventHandler; @@ -57,32 +61,23 @@ public KafkaEventListener(@Autowired RequestEventHandler requestEventHandler, groupId = "${spring.kafka.consumer.group-id}" ) public void handleRequestEvent(String eventString, MessageHeaders messageHeaders) { - log.debug("handleRequestEvent:: event: {}", () -> eventString); - KafkaEvent event = deserialize(eventString, messageHeaders, Request.class); - log.info("handleRequestEvent:: event received: {}", event::getId); - handleEvent(event, requestEventHandler); - log.info("handleRequestEvent:: event consumed: {}", event::getId); + handleEvent(eventString, requestEventHandler, messageHeaders, Request.class); } @KafkaListener( - topicPattern = "${folio.environment}\\.\\w+\\.circulation\\.request-queue-reordering", + topicPattern = "${folio.environment}\\.\\w+\\.circulation\\.loan", groupId = "${spring.kafka.consumer.group-id}" ) - public void handleRequestBatchUpdateEvent(String eventString, MessageHeaders messageHeaders) { - log.debug("handleRequestBatchUpdateEvent:: event: {}", () -> eventString); - KafkaEvent event = deserialize(eventString, messageHeaders, RequestsBatchUpdate.class); - log.info("handleRequestBatchUpdateEvent:: event received: {}", event::getId); - handleEvent(event, requestBatchEventHandler); - log.info("handleRequestBatchUpdateEvent:: event consumed: {}", event::getId); + public void handleLoanEvent(String eventString, MessageHeaders messageHeaders) { + handleEvent(eventString, loanEventHandler, messageHeaders, Loan.class); } - private void handleEvent(KafkaEvent event, KafkaEventHandler handler) { - try { - systemUserScopedExecutionService.executeAsyncSystemUserScoped(CENTRAL_TENANT_ID, - () -> handler.handle(event)); - } catch (Exception e) { - log.error("handleEvent:: Failed to handle Kafka event in tenant {}", CENTRAL_TENANT_ID); - } + @KafkaListener( + topicPattern = "${folio.environment}\\.\\w+\\.circulation\\.request-queue-reordering", + groupId = "${spring.kafka.consumer.group-id}" + ) + public void handleRequestBatchUpdateEvent(String eventString, MessageHeaders messageHeaders) { + handleEvent(eventString, requestBatchEventHandler, messageHeaders, RequestsBatchUpdate.class); } @KafkaListener( @@ -90,11 +85,7 @@ private void handleEvent(KafkaEvent event, KafkaEventHandler handler) groupId = "${spring.kafka.consumer.group-id}" ) public void handleUserGroupEvent(String eventString, MessageHeaders messageHeaders) { - KafkaEvent event = deserialize(eventString, messageHeaders, UserGroup.class); - - log.info("handleUserGroupEvent:: event received: {}", event::getId); - log.debug("handleUserGroupEvent:: event: {}", () -> event); - handleEvent(event, userGroupEventHandler); + handleEvent(eventString, userGroupEventHandler, messageHeaders, UserGroup.class); } @KafkaListener( @@ -102,10 +93,22 @@ public void handleUserGroupEvent(String eventString, MessageHeaders messageHeade groupId = "${spring.kafka.consumer.group-id}" ) public void handleUserEvent(String eventString, MessageHeaders messageHeaders) { - KafkaEvent event = deserialize(eventString, messageHeaders, User.class); + handleEvent(eventString, userEventHandler, messageHeaders, User.class); + } + + private void handleEvent(String eventString, KafkaEventHandler handler, + MessageHeaders messageHeaders, Class payloadType) { - log.info("handleUserEvent:: event received: {}", event::getId); - handleEvent(event, userEventHandler); + log.debug("handleEvent:: event: {}", () -> eventString); + KafkaEvent event = deserialize(eventString, messageHeaders, payloadType); + log.info("handleEvent:: event received: {}", event::getId); + try { + systemUserScopedExecutionService.executeAsyncSystemUserScoped(CENTRAL_TENANT_ID, + () -> handler.handle(event)); + } catch (Exception e) { + log.error("handleEvent:: failed to handle event {}", event.getId(), e); + } + log.info("handleEvent:: event consumed: {}", event::getId); } private static KafkaEvent deserialize(String eventString, MessageHeaders messageHeaders, diff --git a/src/main/java/org/folio/repository/EcsTlrRepository.java b/src/main/java/org/folio/repository/EcsTlrRepository.java index 1679554a..c80cce38 100644 --- a/src/main/java/org/folio/repository/EcsTlrRepository.java +++ b/src/main/java/org/folio/repository/EcsTlrRepository.java @@ -14,4 +14,5 @@ public interface EcsTlrRepository extends JpaRepository { Optional findByPrimaryRequestId(UUID primaryRequestId); Optional findByInstanceId(UUID instanceId); List findByPrimaryRequestIdIn(List primaryRequestIds); + List findByItemIdAndRequesterId(UUID itemId, UUID requesterId); } diff --git a/src/main/java/org/folio/service/DcbService.java b/src/main/java/org/folio/service/DcbService.java index 7b8cb372..5bf75405 100644 --- a/src/main/java/org/folio/service/DcbService.java +++ b/src/main/java/org/folio/service/DcbService.java @@ -13,7 +13,8 @@ public interface DcbService { void createBorrowerTransaction(EcsTlrEntity ecsTlr, Request request); void createBorrowingPickupTransaction(EcsTlrEntity ecsTlr, Request request); void createPickupTransaction(EcsTlrEntity ecsTlr, Request request); + void updateTransactionStatuses(TransactionStatus.StatusEnum newStatus, EcsTlrEntity ecsTlr); TransactionStatusResponse getTransactionStatus(UUID transactionId, String tenantId); - TransactionStatusResponse updateTransactionStatus(UUID transactionId, - TransactionStatus.StatusEnum newStatus, String tenantId); + void updateTransactionStatus(UUID transactionId, TransactionStatus.StatusEnum newStatus, + String tenantId); } diff --git a/src/main/java/org/folio/service/impl/DcbServiceImpl.java b/src/main/java/org/folio/service/impl/DcbServiceImpl.java index 99017e46..3408e23f 100644 --- a/src/main/java/org/folio/service/impl/DcbServiceImpl.java +++ b/src/main/java/org/folio/service/impl/DcbServiceImpl.java @@ -4,6 +4,13 @@ import static org.folio.domain.dto.DcbTransaction.RoleEnum.BORROWING_PICKUP; import static org.folio.domain.dto.DcbTransaction.RoleEnum.LENDER; import static org.folio.domain.dto.DcbTransaction.RoleEnum.PICKUP; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.AWAITING_PICKUP; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.CANCELLED; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.CLOSED; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.CREATED; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.ITEM_CHECKED_IN; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.ITEM_CHECKED_OUT; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.OPEN; import java.util.UUID; @@ -14,6 +21,7 @@ import org.folio.domain.dto.DcbTransaction.RoleEnum; import org.folio.domain.dto.Request; import org.folio.domain.dto.TransactionStatus; +import org.folio.domain.dto.TransactionStatus.StatusEnum; import org.folio.domain.dto.TransactionStatusResponse; import org.folio.domain.entity.EcsTlrEntity; import org.folio.service.DcbService; @@ -21,6 +29,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import feign.FeignException; import lombok.extern.log4j.Log4j2; @Service @@ -114,18 +123,6 @@ public TransactionStatusResponse getTransactionStatus(UUID transactionId, String () -> dcbTransactionClient.getDcbTransactionStatus(transactionId.toString())); } - @Override - public TransactionStatusResponse updateTransactionStatus(UUID transactionId, - TransactionStatus.StatusEnum newStatus, String tenantId) { - - log.info("updateTransactionStatus:: transactionId={}, newStatus={}, tenantId={}", - transactionId, newStatus, tenantId); - - return executionService.executeSystemUserScoped(tenantId, - () -> dcbTransactionClient.changeDcbTransactionStatus( - transactionId.toString(), new TransactionStatus().status(newStatus))); - } - @Override public void createTransactions(EcsTlrEntity ecsTlr, Request secondaryRequest) { log.info("createTransactions:: creating transactions for ECS TLR {}", ecsTlr::getId); @@ -143,4 +140,89 @@ public void createTransactions(EcsTlrEntity ecsTlr, Request secondaryRequest) { } } + @Override + public void updateTransactionStatuses(TransactionStatus.StatusEnum newStatus, EcsTlrEntity ecsTlr) { + log.info("updateTransactionStatuses:: updating primary transaction status to {}", newStatus::getValue); + updateTransactionStatus(ecsTlr.getPrimaryRequestDcbTransactionId(), newStatus, + ecsTlr.getPrimaryRequestTenantId()); + + log.info("updateTransactionStatuses:: updating intermediate transaction status to {}", newStatus::getValue); + updateTransactionStatus(ecsTlr.getIntermediateRequestDcbTransactionId(), newStatus, + ecsTlr.getIntermediateRequestTenantId()); + + log.info("updateTransactionStatuses:: updating secondary transaction status to {}", newStatus::getValue); + updateTransactionStatus(ecsTlr.getSecondaryRequestDcbTransactionId(), newStatus, + ecsTlr.getSecondaryRequestTenantId()); + } + + @Override + public void updateTransactionStatus(UUID transactionId, StatusEnum newStatus, String tenantId) { + if (transactionId == null) { + log.info("updateTransactionStatus:: transaction ID is null, doing nothing"); + return; + } + if (tenantId == null) { + log.info("updateTransactionStatus:: tenant ID is null, doing nothing"); + return; + } + + try { + if (isTransactionStatusChangeAllowed(transactionId, newStatus, tenantId)) { + log.info("updateTransactionStatus: changing status of transaction {} in tenant {} to {}", + transactionId, tenantId, newStatus.getValue()); + + executionService.executeSystemUserScoped(tenantId, + () -> dcbTransactionClient.changeDcbTransactionStatus(transactionId.toString(), + new TransactionStatus().status(newStatus))); + } + } catch (FeignException.NotFound e) { + log.error("updateTransactionStatus:: transaction {} not found: {}", transactionId, e.getMessage()); + } catch (Exception e) { + log.error("updateTransactionStatus:: failed to update transaction status: {}", e::getMessage); + log.debug("updateTransactionStatus:: ", e); + } + } + + private boolean isTransactionStatusChangeAllowed(UUID transactionId, StatusEnum newStatus, + String tenantId) { + + TransactionStatusResponse transaction = getTransactionStatus(transactionId, tenantId); + RoleEnum transactionRole = RoleEnum.fromValue(transaction.getRole().getValue()); + StatusEnum currentStatus = StatusEnum.fromValue(transaction.getStatus().getValue()); + + return isTransactionStatusChangeAllowed(transactionRole, currentStatus, newStatus); + } + + private static boolean isTransactionStatusChangeAllowed(RoleEnum role, StatusEnum oldStatus, + StatusEnum newStatus) { + + log.info("isTransactionStatusChangeAllowed:: role={}, oldStatus={}, newStatus={}", role, + oldStatus, newStatus); + + boolean isStatusChangeAllowed = false; + + if (role == LENDER) { + isStatusChangeAllowed = (oldStatus == CREATED && newStatus == OPEN) || + (oldStatus == OPEN && newStatus == AWAITING_PICKUP) || + (oldStatus == AWAITING_PICKUP && newStatus == ITEM_CHECKED_OUT) || + (oldStatus == ITEM_CHECKED_OUT && newStatus == ITEM_CHECKED_IN) || + (oldStatus != CANCELLED && newStatus == CANCELLED); + } + else if (role == BORROWER) { + isStatusChangeAllowed = (oldStatus == CREATED && newStatus == OPEN) || + (oldStatus == OPEN && newStatus == AWAITING_PICKUP) || + (oldStatus == AWAITING_PICKUP && newStatus == ITEM_CHECKED_OUT) || + (oldStatus == ITEM_CHECKED_OUT && newStatus == ITEM_CHECKED_IN) || + (oldStatus == ITEM_CHECKED_IN && newStatus == CLOSED) || + (oldStatus != CANCELLED && newStatus == CANCELLED); + } + else if (role == BORROWING_PICKUP || role == PICKUP) { + isStatusChangeAllowed = (oldStatus == CREATED && newStatus == OPEN) || + (oldStatus == ITEM_CHECKED_IN && newStatus == CLOSED) || + (oldStatus != CANCELLED && newStatus == CANCELLED); + } + log.info("isTransactionStatusChangeAllowed:: status change is allowed: {}", isStatusChangeAllowed); + return isStatusChangeAllowed; + } + } diff --git a/src/main/java/org/folio/service/impl/LoanEventHandler.java b/src/main/java/org/folio/service/impl/LoanEventHandler.java new file mode 100644 index 00000000..1e21c8ef --- /dev/null +++ b/src/main/java/org/folio/service/impl/LoanEventHandler.java @@ -0,0 +1,143 @@ +package org.folio.service.impl; + +import static org.folio.domain.dto.TransactionStatusResponse.RoleEnum.BORROWING_PICKUP; +import static org.folio.domain.dto.TransactionStatusResponse.RoleEnum.LENDER; +import static org.folio.domain.dto.TransactionStatusResponse.RoleEnum.PICKUP; +import static org.folio.domain.dto.TransactionStatusResponse.StatusEnum.CLOSED; +import static org.folio.domain.dto.TransactionStatusResponse.StatusEnum.ITEM_CHECKED_IN; +import static org.folio.domain.dto.TransactionStatusResponse.StatusEnum.ITEM_CHECKED_OUT; +import static org.folio.support.KafkaEvent.EventType.UPDATED; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.UUID; + +import org.folio.domain.dto.Loan; +import org.folio.domain.dto.TransactionStatus.StatusEnum; +import org.folio.domain.dto.TransactionStatusResponse; +import org.folio.domain.dto.TransactionStatusResponse.RoleEnum; +import org.folio.domain.entity.EcsTlrEntity; +import org.folio.repository.EcsTlrRepository; +import org.folio.service.DcbService; +import org.folio.service.KafkaEventHandler; +import org.folio.support.KafkaEvent; +import org.springframework.stereotype.Service; + +import lombok.AllArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@AllArgsConstructor +@Service +@Log4j2 +public class LoanEventHandler implements KafkaEventHandler { + private static final String LOAN_ACTION_CHECKED_IN = "checkedin"; + private static final EnumSet + RELEVANT_TRANSACTION_STATUSES_FOR_CHECK_IN = EnumSet.of(ITEM_CHECKED_OUT, ITEM_CHECKED_IN, CLOSED); + + private final DcbService dcbService; + private final EcsTlrRepository ecsTlrRepository; + + @Override + public void handle(KafkaEvent event) { + log.info("handle:: processing loan event: {}", event::getId); + if (event.getType() == UPDATED) { + handleUpdateEvent(event); + } else { + log.info("handle:: ignoring event {} of unsupported type: {}", event::getId, event::getType); + } + log.info("handle:: loan event processed: {}", event::getId); + } + + private void handleUpdateEvent(KafkaEvent event) { + Loan loan = event.getData().getNewVersion(); + String loanAction = loan.getAction(); + log.info("handle:: loan action: {}", loanAction); + if (LOAN_ACTION_CHECKED_IN.equals(loanAction)) { + log.info("handleUpdateEvent:: processing loan check-in event: {}", event::getId); + handleCheckInEvent(event); + } else { + log.info("handleUpdateEvent:: ignoring loan update event with unsupported loan action: {}", loanAction); + } + } + + private void handleCheckInEvent(KafkaEvent event) { + updateEcsTlr(event.getData().getNewVersion(), event.getTenant()); + } + + private void updateEcsTlr(Loan loan, String tenantId) { + Collection ecsTlrs = findEcsTlrs(loan); + for (EcsTlrEntity ecsTlr : ecsTlrs) { + log.info("updateEcsTlr:: checking ECS TLR {}", ecsTlr::getId); + String primaryTenantId = ecsTlr.getPrimaryRequestTenantId(); + String secondaryTenantId = ecsTlr.getSecondaryRequestTenantId(); + UUID primaryTransactionId = ecsTlr.getPrimaryRequestDcbTransactionId(); + UUID secondaryTransactionId = ecsTlr.getSecondaryRequestDcbTransactionId(); + + if (primaryTransactionId == null || secondaryTransactionId == null) { + log.info("updateEcsTlr:: ECS TLR does not have primary/secondary transaction, skipping"); + continue; + } + + boolean eventTenantIdIsPrimaryTenantId = tenantId.equals(primaryTenantId); + boolean eventTenantIdIsSecondaryTenantId = tenantId.equals(secondaryTenantId); + if (!(eventTenantIdIsPrimaryTenantId || eventTenantIdIsSecondaryTenantId)) { + log.info("updateEcsTlr:: event tenant ID does not match ECS TLR's primary/secondary request " + + "tenant ID, skipping"); + continue; + } + + TransactionStatusResponse primaryTransaction = dcbService.getTransactionStatus( + primaryTransactionId, primaryTenantId); + TransactionStatusResponse.StatusEnum primaryTransactionStatus = primaryTransaction.getStatus(); + RoleEnum primaryTransactionRole = primaryTransaction.getRole(); + log.info("updateEcsTlr:: primary request transaction: status={}, role={}", + primaryTransactionStatus, primaryTransactionRole); + if (!RELEVANT_TRANSACTION_STATUSES_FOR_CHECK_IN.contains(primaryTransactionStatus)) { + log.info("updateEcsTlrForLoan:: irrelevant primary request transaction status: {}", + primaryTransaction); + continue; + } + + TransactionStatusResponse secondaryTransaction = dcbService.getTransactionStatus( + secondaryTransactionId, secondaryTenantId); + TransactionStatusResponse.StatusEnum secondaryTransactionStatus = secondaryTransaction.getStatus(); + RoleEnum secondaryTransactionRole = secondaryTransaction.getRole(); + log.info("updateEcsTlr:: secondary request transaction: status={}, role={}", + secondaryTransactionStatus, secondaryTransactionRole); + if (!RELEVANT_TRANSACTION_STATUSES_FOR_CHECK_IN.contains(secondaryTransactionStatus)) { + log.info("updateEcsTlr:: irrelevant secondary request transaction status: {}", + secondaryTransactionStatus); + continue; + } + + if (eventTenantIdIsPrimaryTenantId && + (primaryTransactionRole == BORROWING_PICKUP || primaryTransactionRole == PICKUP) && + (primaryTransactionStatus == ITEM_CHECKED_OUT || primaryTransactionStatus == ITEM_CHECKED_IN) && + secondaryTransactionRole == LENDER && secondaryTransactionStatus == ITEM_CHECKED_OUT) { + + log.info("updateEcsTlr:: check-in happened in primary request tenant ({}), updating transactions", + primaryTenantId); + dcbService.updateTransactionStatuses(StatusEnum.ITEM_CHECKED_IN, ecsTlr); + return; + } + else if (eventTenantIdIsSecondaryTenantId && secondaryTransactionRole == LENDER && + (secondaryTransactionStatus == ITEM_CHECKED_IN || secondaryTransactionStatus == CLOSED) && + (primaryTransactionRole == BORROWING_PICKUP || primaryTransactionRole == PICKUP) && + primaryTransactionStatus == ITEM_CHECKED_IN) { + + log.info("updateEcsTlr:: check-in happened in secondary request tenant ({}), updating transactions", secondaryTenantId); + dcbService.updateTransactionStatuses(StatusEnum.CLOSED, ecsTlr); + return; + } + log.info("updateEcsTlr:: ECS TLR {} was not updated", ecsTlr::getId); + } + log.info("updateEcsTlr:: suitable ECS TLR for loan {} in tenant {} was not found", loan.getId(), tenantId); + } + + private Collection findEcsTlrs(Loan loan) { + log.info("findEcsTlr:: searching ECS TLRs for loan {}", loan::getId); + return ecsTlrRepository.findByItemIdAndRequesterId(UUID.fromString(loan.getItemId()), + UUID.fromString(loan.getUserId())); + } + +} diff --git a/src/main/java/org/folio/service/impl/RequestEventHandler.java b/src/main/java/org/folio/service/impl/RequestEventHandler.java index f8087ace..e367cc41 100644 --- a/src/main/java/org/folio/service/impl/RequestEventHandler.java +++ b/src/main/java/org/folio/service/impl/RequestEventHandler.java @@ -30,7 +30,6 @@ import org.folio.support.KafkaEvent; import org.springframework.stereotype.Service; -import feign.FeignException; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -149,7 +148,7 @@ private static Optional determineNewTransactionSta oldRequestStatus, newRequestStatus); if (newRequestStatus == oldRequestStatus) { - log.info("determineNewTransactionStatus:: request status did not change"); + log.info("determineNewTransactionStatus:: request status did not change, doing nothing"); return Optional.empty(); } @@ -171,51 +170,7 @@ private static Optional determineNewTransactionSta private void updateTransactionStatuses(KafkaEvent event, EcsTlrEntity ecsTlr) { determineNewTransactionStatus(event) - .ifPresent(newStatus -> updateTransactionStatuses(newStatus, ecsTlr)); - } - - private void updateTransactionStatuses(TransactionStatus.StatusEnum newStatus, EcsTlrEntity ecsTlr) { - log.info("updateTransactionStatuses:: updating primary transaction status to {}", newStatus::getValue); - updateTransactionStatus(ecsTlr.getPrimaryRequestDcbTransactionId(), newStatus, - ecsTlr.getPrimaryRequestTenantId()); - - log.info("updateTransactionStatuses:: updating intermediate transaction status to {}", newStatus::getValue); - updateTransactionStatus(ecsTlr.getIntermediateRequestDcbTransactionId(), newStatus, - ecsTlr.getIntermediateRequestTenantId()); - - log.info("updateTransactionStatuses:: updating secondary transaction status to {}", newStatus::getValue); - updateTransactionStatus(ecsTlr.getSecondaryRequestDcbTransactionId(), newStatus, - ecsTlr.getSecondaryRequestTenantId()); - } - - private void updateTransactionStatus(UUID transactionId, - TransactionStatus.StatusEnum newStatus, String tenantId) { - - if (transactionId == null) { - log.info("updateTransactionStatus:: transaction ID is null, doing nothing"); - return; - } - if (tenantId == null) { - log.info("updateTransactionStatus:: tenant ID is null, doing nothing"); - return; - } - - try { - var currentStatus = dcbService.getTransactionStatus(transactionId, tenantId).getStatus(); - log.info("updateTransactionStatus:: current transaction status: {}", currentStatus); - if (newStatus.getValue().equals(currentStatus.getValue())) { - log.info("updateTransactionStatus:: transaction status did not change, doing nothing"); - return; - } - log.info("updateTransactionStatus: changing status of transaction {} in tenant {} from {} to {}", - transactionId, tenantId, currentStatus.getValue(), newStatus.getValue()); - dcbService.updateTransactionStatus(transactionId, newStatus, tenantId); - } catch (FeignException.NotFound e) { - log.error("updateTransactionStatus:: transaction {} not found: {}", transactionId, e.getMessage()); - } catch (Exception e) { - log.error("updateTransactionStatus:: failed to update transaction status: {}", e::getMessage); - log.debug("updateTransactionStatus:: ", e); - } + .ifPresent(newStatus -> dcbService.updateTransactionStatuses(newStatus, ecsTlr)); } private void propagateChangesFromPrimaryToSecondaryRequest(EcsTlrEntity ecsTlr, diff --git a/src/main/resources/swagger.api/ecs-tlr.yaml b/src/main/resources/swagger.api/ecs-tlr.yaml index dd60c16e..b1d4460c 100644 --- a/src/main/resources/swagger.api/ecs-tlr.yaml +++ b/src/main/resources/swagger.api/ecs-tlr.yaml @@ -103,6 +103,8 @@ components: $ref: 'schemas/request.json' requests: $ref: 'schemas/requests.json' + loan: + $ref: 'schemas/loan.json' searchInstancesResponse: $ref: 'schemas/search/searchInstancesResponse.yaml' searchItemResponse: diff --git a/src/main/resources/swagger.api/schemas/loan.json b/src/main/resources/swagger.api/schemas/loan.json new file mode 100644 index 00000000..1ab9653f --- /dev/null +++ b/src/main/resources/swagger.api/schemas/loan.json @@ -0,0 +1,166 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "title": "Loan", + "description": "Links the item with the patron and applies certain conditions based on policies", + "properties": { + "id": { + "description": "Unique ID (generated UUID) of the loan", + "type": "string" + }, + "userId": { + "description": "ID of the patron the item was lent to. Required for open loans, not required for closed loans (for anonymization).", + "type": "string" + }, + "proxyUserId": { + "description": "ID of the user representing a proxy for the patron", + "type": "string", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "itemId": { + "description": "ID of the item lent to the patron", + "type": "string" + }, + "itemEffectiveLocationIdAtCheckOut": { + "description": "The effective location, at the time of checkout, of the item loaned to the patron.", + "type": "string", + "$ref": "uuid.json" + }, + "status": { + "description": "Overall status of the loan", + "type": "object", + "properties": { + "name": { + "description": "Name of the status (currently can be any value, values commonly used are Open and Closed)", + "type": "string" + } + } + }, + "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" + }, + "dueDate": { + "description": "Date time when the item is due to be returned", + "type": "string", + "format": "date-time" + }, + "returnDate": { + "description": "Date time when the item is returned and the loan ends (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" + }, + "systemReturnDate" : { + "description": "Date time when the returned item is actually processed", + "type": "string", + "format": "date-time" + }, + "action": { + "description": "Last action performed on a loan (currently can be any value, values commonly used are checkedout and checkedin)", + "type": "string" + }, + "actionComment": { + "description": "Comment to last action performed on a loan", + "type": "string" + }, + "itemStatus": { + "description": "Last item status used in relation to this loan (currently can be any value, values commonly used are Checked out and Available)", + "type": "string" + }, + "renewalCount": { + "description": "Count of how many times a loan has been renewed (incremented by the client)", + "type": "integer" + }, + "loanPolicyId": { + "description": "ID of last policy used in relation to this loan", + "type": "string" + }, + "checkoutServicePointId": { + "description": "ID of the Service Point where the last checkout occured", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "checkinServicePointId": { + "description": "ID of the Service Point where the last checkin occured", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "patronGroupIdAtCheckout": { + "description": "Patron Group Id at checkout", + "type": "string" + }, + "dueDateChangedByRecall": { + "description": "Indicates whether or not this loan had its due date modified by a recall on the loaned item", + "type": "boolean" + }, + "isDcb": { + "description": "Indicates whether or not this loan is associated for DCB use case", + "type": "boolean" + }, + "declaredLostDate" : { + "description": "Date and time the item was declared lost during this loan", + "type": "string", + "format": "date-time" + }, + "claimedReturnedDate": { + "description": "Date and time the item was claimed returned for this loan", + "type": "string", + "format": "date-time" + }, + "overdueFinePolicyId": { + "description": "ID of overdue fines policy at the time the item is check-in or renewed", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "lostItemPolicyId": { + "description": "ID of lost item policy which determines when the item ages to lost and the associated fees or the associated fees if the patron declares the item lost.", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "metadata": { + "description": "Metadata about creation and changes to loan, provided by the server (client should not provide)", + "type": "object", + "$ref": "metadata.json" + }, + "agedToLostDelayedBilling": { + "description": "Aged to Lost Delayed Billing processing", + "type": "object", + "properties": { + "lostItemHasBeenBilled": { + "description": "Indicates if the aged to lost fee has been billed (for use where delayed billing is set up)", + "type": "boolean" + }, + "dateLostItemShouldBeBilled": { + "description": "Indicates when the aged to lost fee should be billed (for use where delayed billing is set up)", + "type": "string", + "format": "date-time" + }, + "agedToLostDate": { + "description": "Date and time the item was aged to lost for this loan", + "type": "string", + "format": "date-time" + } + } + }, + "reminders" : { + "description": "Information about reminders for overdue loan", + "type": "object", + "properties": { + "lastFeeBilled": { + "description": "Information about the most recent reminder fee billing", + "type": "object", + "properties": { + "number": { + "description": "Last reminder fee billed, sequence number", + "type": "integer" + }, + "date": { + "description": "Last reminder fee billed, date", + "type": "string", + "format": "date-time" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/test/java/org/folio/listener/KafkaEventListenerTest.java b/src/test/java/org/folio/listener/KafkaEventListenerTest.java index 759f8272..fe5f8f5d 100644 --- a/src/test/java/org/folio/listener/KafkaEventListenerTest.java +++ b/src/test/java/org/folio/listener/KafkaEventListenerTest.java @@ -8,6 +8,7 @@ import java.util.Map; import org.folio.listener.kafka.KafkaEventListener; +import org.folio.service.impl.LoanEventHandler; import org.folio.service.impl.RequestBatchUpdateEventHandler; import org.folio.service.impl.RequestEventHandler; import org.folio.service.impl.UserEventHandler; @@ -24,6 +25,8 @@ class KafkaEventListenerTest { @Mock RequestEventHandler requestEventHandler; @Mock + LoanEventHandler loanEventHandler; + @Mock RequestBatchUpdateEventHandler requestBatchEventHandler; @Mock SystemUserScopedExecutionService systemUserScopedExecutionService; @@ -37,8 +40,8 @@ void shouldHandleExceptionInEventHandler() { doThrow(new NullPointerException("NPE")).when(systemUserScopedExecutionService) .executeAsyncSystemUserScoped(any(), any()); KafkaEventListener kafkaEventListener = new KafkaEventListener(requestEventHandler, - requestBatchEventHandler, systemUserScopedExecutionService, userGroupEventHandler, - userEventHandler); + loanEventHandler, requestBatchEventHandler, systemUserScopedExecutionService, + userGroupEventHandler, userEventHandler); kafkaEventListener.handleRequestEvent("{}", new MessageHeaders(Map.of(TENANT, "default".getBytes()))); diff --git a/src/test/java/org/folio/service/DcbServiceTest.java b/src/test/java/org/folio/service/DcbServiceTest.java new file mode 100644 index 00000000..3b7eb402 --- /dev/null +++ b/src/test/java/org/folio/service/DcbServiceTest.java @@ -0,0 +1,103 @@ +package org.folio.service; + +import static java.util.UUID.randomUUID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import java.util.concurrent.Callable; + +import org.folio.client.feign.DcbTransactionClient; +import org.folio.domain.dto.TransactionStatus; +import org.folio.domain.dto.TransactionStatusResponse; +import org.folio.service.impl.DcbServiceImpl; +import org.folio.spring.service.SystemUserScopedExecutionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DcbServiceTest { + + @Mock + private DcbTransactionClient dcbTransactionClient; + @Mock + private SystemUserScopedExecutionService executionService; + @InjectMocks + private DcbServiceImpl dcbService; + + @BeforeEach + public void setup() { + // Bypass the use of system user and return the result of Callable immediately + when(executionService.executeSystemUserScoped(any(String.class), any(Callable.class))) + .thenAnswer(invocation -> invocation.getArgument(1, Callable.class).call()); + } + + @ParameterizedTest + @CsvSource(value = { + "PICKUP, CREATED, OPEN, true", + "PICKUP, OPEN, AWAITING_PICKUP, false", + "PICKUP, AWAITING_PICKUP, ITEM_CHECKED_OUT, false", + "PICKUP, ITEM_CHECKED_OUT, ITEM_CHECKED_IN, false", + "PICKUP, ITEM_CHECKED_IN, CLOSED, true", + "PICKUP, OPEN, CANCELLED, true", + + "BORROWING-PICKUP, CREATED, OPEN, true", + "BORROWING-PICKUP, OPEN, AWAITING_PICKUP, false", + "BORROWING-PICKUP, AWAITING_PICKUP, ITEM_CHECKED_OUT, false", + "BORROWING-PICKUP, ITEM_CHECKED_OUT, ITEM_CHECKED_IN, false", + "BORROWING-PICKUP, ITEM_CHECKED_IN, CLOSED, true", + "BORROWING-PICKUP, OPEN, CANCELLED, true", + + "BORROWER, CREATED, OPEN, true", + "BORROWER, OPEN, AWAITING_PICKUP, true", + "BORROWER, AWAITING_PICKUP, ITEM_CHECKED_OUT, true", + "BORROWER, ITEM_CHECKED_OUT, ITEM_CHECKED_IN, true", + "BORROWER, ITEM_CHECKED_IN, CLOSED, true", + "BORROWER, OPEN, CANCELLED, true", + + "LENDER, CREATED, OPEN, true", + "LENDER, OPEN, AWAITING_PICKUP, true", + "LENDER, AWAITING_PICKUP, ITEM_CHECKED_OUT, true", + "LENDER, ITEM_CHECKED_OUT, ITEM_CHECKED_IN, true", + "LENDER, ITEM_CHECKED_IN, CLOSED, false", + "LENDER, OPEN, CANCELLED, true", + }) + void updateTransactionStatusesUpdatesAllTransactions(String role, String oldStatus, + String newStatus, boolean transactionUpdateIsExpected) { + + String transactionId = randomUUID().toString(); + TransactionStatus newTransactionStatus = new TransactionStatus().status( + TransactionStatus.StatusEnum.fromValue(newStatus)); + + TransactionStatusResponse mockGetStatusResponse = buildTransactionStatusResponse(role, oldStatus); + TransactionStatusResponse mockUpdateStatusResponse = buildTransactionStatusResponse(role, newStatus); + + when(dcbTransactionClient.getDcbTransactionStatus(transactionId)) + .thenReturn(mockGetStatusResponse); + + if (transactionUpdateIsExpected) { + when(dcbTransactionClient.changeDcbTransactionStatus(transactionId, newTransactionStatus)) + .thenReturn(mockUpdateStatusResponse); + } + + dcbService.updateTransactionStatus(UUID.fromString(transactionId), + newTransactionStatus.getStatus(), "test_tenant"); + + verify(dcbTransactionClient, times(transactionUpdateIsExpected ? 1 : 0)) + .changeDcbTransactionStatus(transactionId, newTransactionStatus); + } + + private static TransactionStatusResponse buildTransactionStatusResponse(String role, String status) { + return new TransactionStatusResponse() + .role(TransactionStatusResponse.RoleEnum.fromValue(role)) + .status(TransactionStatusResponse.StatusEnum.fromValue(status)); + } + +} \ No newline at end of file diff --git a/src/test/java/org/folio/service/LoanEventHandlerTest.java b/src/test/java/org/folio/service/LoanEventHandlerTest.java new file mode 100644 index 00000000..a9afe582 --- /dev/null +++ b/src/test/java/org/folio/service/LoanEventHandlerTest.java @@ -0,0 +1,199 @@ +package org.folio.service; + +import static java.util.Collections.emptyList; +import static java.util.UUID.randomUUID; +import static org.folio.support.KafkaEvent.EventType.UPDATED; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; + +import org.folio.domain.dto.Loan; +import org.folio.domain.dto.TransactionStatus; +import org.folio.domain.dto.TransactionStatusResponse; +import org.folio.domain.entity.EcsTlrEntity; +import org.folio.repository.EcsTlrRepository; +import org.folio.service.impl.LoanEventHandler; +import org.folio.support.KafkaEvent; +import org.folio.support.KafkaEvent.EventType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LoanEventHandlerTest { + + private static final EnumSet SUPPORTED_EVENT_TYPES = EnumSet.of(UPDATED); + + @Mock + private DcbService dcbService; + @Mock + private EcsTlrRepository ecsTlrRepository; + @InjectMocks + private LoanEventHandler loanEventHandler; + + @ParameterizedTest + @EnumSource(EventType.class) + void eventsOfUnsupportedTypesAreIgnored(EventType eventType) { + if (!SUPPORTED_EVENT_TYPES.contains(eventType)) { + loanEventHandler.handle(new KafkaEvent<>(null, null, eventType, 0L, null, null)); + verifyNoInteractions(ecsTlrRepository, dcbService); + } + } + + @Test + void updateEventForLoanWithUnsupportedActionInIgnored() { + Loan loan = new Loan().action("random_action"); + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), "test_tenant", UPDATED, + 0L, new KafkaEvent.EventData<>(loan, loan), "test_tenant"); + loanEventHandler.handle(event); + verifyNoInteractions(ecsTlrRepository, dcbService); + } + + @Test + void checkInEventIsIgnoredWhenEcsTlrForUpdatedLoanIsNotFound() { + UUID itemId = randomUUID(); + UUID userId = randomUUID(); + Loan loan = new Loan() + .id(randomUUID().toString()) + .action("checkedin") + .itemId(itemId.toString()) + .userId(userId.toString()); + + when(ecsTlrRepository.findByItemIdAndRequesterId(itemId, userId)) + .thenReturn(emptyList()); + + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), "test_tenant", UPDATED, + 0L, new KafkaEvent.EventData<>(loan, loan), "test_tenant"); + loanEventHandler.handle(event); + + verify(ecsTlrRepository).findByItemIdAndRequesterId(itemId, userId); + verifyNoInteractions(dcbService); + } + + @Test + void checkInEventIsIgnoredWhenEcsTlrDoesNotContainsNoTransactionIds() { + UUID itemId = randomUUID(); + UUID userId = randomUUID(); + Loan loan = new Loan() + .id(randomUUID().toString()) + .action("checkedin") + .itemId(itemId.toString()) + .userId(userId.toString()); + + when(ecsTlrRepository.findByItemIdAndRequesterId(itemId, userId)) + .thenReturn(List.of(new EcsTlrEntity())); + + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), "test_tenant", UPDATED, + 0L, new KafkaEvent.EventData<>(loan, loan), "test_tenant"); + loanEventHandler.handle(event); + + verify(ecsTlrRepository).findByItemIdAndRequesterId(itemId, userId); + verifyNoInteractions(dcbService); + } + + @Test + void checkInEventIsIgnoredWhenEventTenantDoesNotMatchEcsRequestTransactionTenants() { + UUID itemId = randomUUID(); + UUID userId = randomUUID(); + Loan loan = new Loan() + .id(randomUUID().toString()) + .action("checkedin") + .itemId(itemId.toString()) + .userId(userId.toString()); + + EcsTlrEntity ecsTlr = new EcsTlrEntity(); + ecsTlr.setPrimaryRequestTenantId("borrowing_tenant"); + ecsTlr.setSecondaryRequestTenantId("lending_tenant"); + ecsTlr.setPrimaryRequestDcbTransactionId(randomUUID()); + ecsTlr.setSecondaryRequestDcbTransactionId(randomUUID()); + + when(ecsTlrRepository.findByItemIdAndRequesterId(itemId, userId)) + .thenReturn(List.of(ecsTlr)); + + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), "test_tenant", UPDATED, + 0L, new KafkaEvent.EventData<>(loan, loan), "test_tenant"); + loanEventHandler.handle(event); + + verify(ecsTlrRepository).findByItemIdAndRequesterId(itemId, userId); + verifyNoInteractions(dcbService); + } + + @ParameterizedTest + @CsvSource(value = { + "BORROWING-PICKUP, ITEM_CHECKED_OUT, LENDER, ITEM_CHECKED_OUT, borrowing_tenant, ITEM_CHECKED_IN", + "BORROWING-PICKUP, ITEM_CHECKED_IN, LENDER, ITEM_CHECKED_OUT, borrowing_tenant, ITEM_CHECKED_IN", + "PICKUP, ITEM_CHECKED_OUT, LENDER, ITEM_CHECKED_OUT, borrowing_tenant, ITEM_CHECKED_IN", + "PICKUP, ITEM_CHECKED_IN, LENDER, ITEM_CHECKED_OUT, borrowing_tenant, ITEM_CHECKED_IN", + + "BORROWING-PICKUP, ITEM_CHECKED_IN, LENDER, ITEM_CHECKED_IN, lending_tenant, CLOSED", + "BORROWING-PICKUP, ITEM_CHECKED_IN, LENDER, CLOSED, lending_tenant, CLOSED", + "PICKUP, ITEM_CHECKED_IN, LENDER, ITEM_CHECKED_IN, lending_tenant, CLOSED", + "PICKUP, ITEM_CHECKED_IN, LENDER, CLOSED, lending_tenant, CLOSED" + }) + void checkInEventIsHandled(String primaryTransactionRole, String primaryTransactionStatus, + String secondaryTransactionRole, String secondaryTransactionStatus, String eventTenant, + String expectedNewTransactionStatus) { + + String primaryRequestTenant = "borrowing_tenant"; + String secondaryRequestTenant = "lending_tenant"; + UUID primaryTransactionId = randomUUID(); + UUID secondaryTransactionId = randomUUID(); + UUID itemId = randomUUID(); + UUID userId = randomUUID(); + Loan loan = new Loan() + .action("checkedin") + .itemId(itemId.toString()) + .userId(userId.toString()); + + EcsTlrEntity mockEcsTlr = new EcsTlrEntity(); + mockEcsTlr.setId(randomUUID()); + mockEcsTlr.setPrimaryRequestTenantId(primaryRequestTenant); + mockEcsTlr.setSecondaryRequestTenantId(secondaryRequestTenant); + mockEcsTlr.setPrimaryRequestDcbTransactionId(primaryTransactionId); + mockEcsTlr.setSecondaryRequestDcbTransactionId(secondaryTransactionId); + + when(ecsTlrRepository.findByItemIdAndRequesterId(itemId, userId)) + .thenReturn(List.of(mockEcsTlr)); + + TransactionStatusResponse mockPrimaryTransactionResponse = buildTransactionStatusResponse( + primaryTransactionRole, primaryTransactionStatus); + TransactionStatusResponse mockSecondaryTransactionResponse = buildTransactionStatusResponse( + secondaryTransactionRole, secondaryTransactionStatus); + + when(dcbService.getTransactionStatus(primaryTransactionId, primaryRequestTenant)) + .thenReturn(mockPrimaryTransactionResponse); + when(dcbService.getTransactionStatus(secondaryTransactionId, secondaryRequestTenant)) + .thenReturn(mockSecondaryTransactionResponse); + + TransactionStatus.StatusEnum expectedNewStatus = TransactionStatus.StatusEnum.fromValue( + expectedNewTransactionStatus); + doNothing().when(dcbService).updateTransactionStatuses(expectedNewStatus, mockEcsTlr); + + KafkaEvent.EventData eventData = new KafkaEvent.EventData<>(loan, loan); + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), eventTenant, + UPDATED, 0L, eventData, eventTenant); + + loanEventHandler.handle(event); + + verify(ecsTlrRepository).findByItemIdAndRequesterId(itemId, userId); + verify(dcbService).getTransactionStatus(primaryTransactionId, primaryRequestTenant); + verify(dcbService).getTransactionStatus(secondaryTransactionId, secondaryRequestTenant); + verify(dcbService).updateTransactionStatuses(expectedNewStatus, mockEcsTlr); + } + + private static TransactionStatusResponse buildTransactionStatusResponse(String role, String status) { + return new TransactionStatusResponse() + .role(TransactionStatusResponse.RoleEnum.fromValue(role)) + .status(TransactionStatusResponse.StatusEnum.fromValue(status)); + } +}