diff --git a/src/main/java/org/folio/config/ServicesConfiguration.java b/src/main/java/org/folio/config/ServicesConfiguration.java index 674f3eb5..8866c0d4 100644 --- a/src/main/java/org/folio/config/ServicesConfiguration.java +++ b/src/main/java/org/folio/config/ServicesConfiguration.java @@ -215,9 +215,10 @@ InvoiceCancelService invoiceCancelService(BaseTransactionService baseTransaction OrderLineService orderLineService, OrderService orderService, PoLinePaymentStatusUpdateService poLinePaymentStatusUpdateService, - InvoiceWorkflowDataHolderBuilder invoiceWorkflowDataHolderBuilder) { + InvoiceWorkflowDataHolderBuilder invoiceWorkflowDataHolderBuilder, + BudgetService budgetService) { return new InvoiceCancelService(baseTransactionService, encumbranceService, voucherService, orderLineService, - orderService, poLinePaymentStatusUpdateService, invoiceWorkflowDataHolderBuilder); + orderService, poLinePaymentStatusUpdateService, invoiceWorkflowDataHolderBuilder, budgetService); } @Bean BatchVoucherService batchVoucherService(RestClient restClient) { diff --git a/src/main/java/org/folio/services/invoice/InvoiceCancelService.java b/src/main/java/org/folio/services/invoice/InvoiceCancelService.java index f29864d6..c838537f 100644 --- a/src/main/java/org/folio/services/invoice/InvoiceCancelService.java +++ b/src/main/java/org/folio/services/invoice/InvoiceCancelService.java @@ -13,7 +13,9 @@ import static org.folio.rest.acq.model.finance.Transaction.TransactionType.PAYMENT; import static org.folio.rest.acq.model.finance.Transaction.TransactionType.PENDING_PAYMENT; +import java.util.Collection; import java.util.List; +import java.util.Objects; import io.vertx.core.Future; import lombok.extern.log4j.Log4j2; @@ -26,10 +28,12 @@ import org.folio.rest.acq.model.orders.PoLine; import org.folio.rest.acq.model.orders.PurchaseOrder; import org.folio.rest.core.models.RequestContext; +import org.folio.rest.jaxrs.model.FundDistribution; import org.folio.rest.jaxrs.model.Invoice; import org.folio.rest.jaxrs.model.InvoiceLine; import org.folio.rest.jaxrs.model.Parameter; import org.folio.services.finance.EncumbranceUtils; +import org.folio.services.finance.budget.BudgetService; import org.folio.services.finance.transaction.BaseTransactionService; import org.folio.services.finance.transaction.EncumbranceService; import org.folio.services.order.OrderLineService; @@ -52,6 +56,7 @@ public class InvoiceCancelService { private final OrderService orderService; private final PoLinePaymentStatusUpdateService poLinePaymentStatusUpdateService; private final InvoiceWorkflowDataHolderBuilder holderBuilder; + private final BudgetService budgetService; public InvoiceCancelService(BaseTransactionService baseTransactionService, EncumbranceService encumbranceService, @@ -59,7 +64,8 @@ public InvoiceCancelService(BaseTransactionService baseTransactionService, OrderLineService orderLineService, OrderService orderService, PoLinePaymentStatusUpdateService poLinePaymentStatusUpdateService, - InvoiceWorkflowDataHolderBuilder holderBuilder) { + InvoiceWorkflowDataHolderBuilder holderBuilder, + BudgetService budgetService) { this.baseTransactionService = baseTransactionService; this.encumbranceService = encumbranceService; this.voucherService = voucherService; @@ -67,6 +73,7 @@ public InvoiceCancelService(BaseTransactionService baseTransactionService, this.orderService = orderService; this.poLinePaymentStatusUpdateService = poLinePaymentStatusUpdateService; this.holderBuilder = holderBuilder; + this.budgetService = budgetService; } /** @@ -128,11 +135,49 @@ private void validateCancelInvoice(Invoice invoiceFromStorage) { private Future validateBudgetsStatus(Invoice invoice, List lines, RequestContext requestContext) { List dataHolders = holderBuilder.buildHoldersSkeleton(lines, invoice); return holderBuilder.withBudgets(dataHolders, false, requestContext) + .compose(holders -> checkBudgetsForUnlinkedEncumbrancesToUnrelease(invoice, lines, requestContext)) .onFailure(t -> log.error("validateBudgetsStatus:: Could not find an active budget for the invoice with id {}", invoice.getId(), t)) .mapEmpty(); } + private Future checkBudgetsForUnlinkedEncumbrancesToUnrelease(Invoice invoice, List invoiceLines, + RequestContext requestContext) { + var poLineIds = invoiceLines.stream() + .filter(InvoiceLine::getReleaseEncumbrance) + .map(InvoiceLine::getPoLineId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (poLineIds.isEmpty()) { + return succeededFuture(); + } + var fundIdsFromInvoiceLines = invoiceLines.stream() + .filter(InvoiceLine::getReleaseEncumbrance) + .map(InvoiceLine::getFundDistributions) + .flatMap(Collection::stream) + .map(FundDistribution::getFundId) + .distinct() + .toList(); + String invoiceFiscalYearId = invoice.getFiscalYearId(); + return orderLineService.getPoLinesByIdAndQuery(poLineIds, this::queryToGetPoLinesWithRightPaymentStatusByIds, requestContext) + .compose(poLines -> selectPoLinesWithOpenOrders(poLines, requestContext)) + .map(poLines -> poLines.stream() + .map(PoLine::getFundDistribution) + .flatMap(Collection::stream) + .map(org.folio.rest.acq.model.orders.FundDistribution::getFundId) + .distinct() + .filter(fundId-> !fundIdsFromInvoiceLines.contains(fundId)) + .toList()) + .compose(fundIds -> { + if (fundIds.isEmpty()) { + return succeededFuture(); + } + return budgetService.getBudgetsByFundIds(fundIds, invoiceFiscalYearId, false, requestContext); + }) + .mapEmpty(); + } + private Future> getTransactions(String invoiceId, RequestContext requestContext) { String query = String.format("sourceInvoiceId==%s", invoiceId); List relevantTransactionTypes = List.of(PENDING_PAYMENT, PAYMENT, CREDIT); @@ -168,7 +213,9 @@ private Future cancelVoucher(String invoiceId, RequestContext requestConte private Future updateOrUnreleaseEncumbrances(List invoiceLines, Invoice invoiceFromStorage, RequestContext requestContext) { var poLineIds = invoiceLines.stream() + .filter(InvoiceLine::getReleaseEncumbrance) .map(InvoiceLine::getPoLineId) + .filter(Objects::nonNull) .distinct() .toList(); if (poLineIds.isEmpty()) { @@ -178,7 +225,7 @@ private Future updateOrUnreleaseEncumbrances(List invoiceLine log.info("updateOrUnreleaseEncumbrances:: Updating or unreleasing encumbrances, invoiceId={}...", invoiceId); return orderLineService.getPoLinesByIdAndQuery(poLineIds, this::queryToGetPoLinesWithRightPaymentStatusByIds, requestContext) .compose(poLines -> selectPoLinesWithOpenOrders(poLines, requestContext)) - .compose(poLines -> unreleaseEncumbrancesForPoLines(invoiceLines, poLines, invoiceFromStorage, requestContext)) + .compose(poLines -> unreleaseEncumbrancesForPoLines(poLines, invoiceFromStorage, requestContext)) .recover(t -> { log.error("updateOrUnreleaseEncumbrances:: Failed to update or unrelease encumbrance for po lines, invoiceId={}", invoiceId, t); var causeParam = new Parameter().withKey("cause").withValue(requireNonNullElse(t.getCause(), t).toString()); @@ -213,15 +260,13 @@ private String queryToGetOpenOrdersByIds(List orderIds) { return OPEN_ORDERS_QUERY + AND + convertIdsToCqlQuery(orderIds); } - private Future unreleaseEncumbrancesForPoLines(List invoiceLines, List poLines, - Invoice invoiceFromStorage, RequestContext requestContext) { + private Future unreleaseEncumbrancesForPoLines(List poLines, Invoice invoiceFromStorage, + RequestContext requestContext) { if (CollectionUtils.isEmpty(poLines)) { return succeededFuture(null); } - var poLineIds = invoiceLines.stream() - .filter(InvoiceLine::getReleaseEncumbrance) - .map(InvoiceLine::getPoLineId) - .distinct() + var poLineIds = poLines.stream() + .map(PoLine::getId) .toList(); var fiscalYearId = invoiceFromStorage.getFiscalYearId(); return encumbranceService.getEncumbrancesByPoLineIds(poLineIds, fiscalYearId, requestContext) diff --git a/src/test/java/org/folio/services/invoice/InvoiceCancelServiceTest.java b/src/test/java/org/folio/services/invoice/InvoiceCancelServiceTest.java index 780fb154..2263a2a0 100644 --- a/src/test/java/org/folio/services/invoice/InvoiceCancelServiceTest.java +++ b/src/test/java/org/folio/services/invoice/InvoiceCancelServiceTest.java @@ -28,6 +28,7 @@ import static org.folio.rest.impl.ApiTestBase.X_OKAPI_USER_ID; import static org.folio.services.finance.transaction.BaseTransactionServiceTest.X_OKAPI_TENANT; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; @@ -61,10 +62,12 @@ import org.folio.rest.core.RestClient; import org.folio.rest.core.models.RequestContext; import org.folio.rest.core.models.RequestEntry; +import org.folio.rest.jaxrs.model.FundDistribution; import org.folio.rest.jaxrs.model.Invoice; import org.folio.rest.jaxrs.model.InvoiceCollection; import org.folio.rest.jaxrs.model.InvoiceLine; import org.folio.rest.jaxrs.model.InvoiceLineCollection; +import org.folio.rest.jaxrs.model.Parameter; import org.folio.rest.jaxrs.model.Voucher; import org.folio.rest.jaxrs.model.VoucherCollection; import org.folio.services.exchange.CacheableExchangeRateService; @@ -145,8 +148,8 @@ public void initMocks() { fiscalYearService, fundService, ledgerService, baseTransactionService, budgetService, expenseClassRetrieveService, cacheableExchangeRateService); - cancelService = new InvoiceCancelService(baseTransactionService, encumbranceService, - voucherService, orderLineService, orderService, poLinePaymentStatusUpdateService, holderBuilder); + cancelService = new InvoiceCancelService(baseTransactionService, encumbranceService, voucherService, + orderLineService, orderService, poLinePaymentStatusUpdateService, holderBuilder, budgetService); } @AfterEach @@ -230,6 +233,59 @@ public void validateBudgetWhenCancelInvoiceAndUndefinedFiscalYearTest(VertxTestC }); } + @Test + public void errorWithInactiveBudgetInLinkedPol(VertxTestContext vertxTestContext) { + String invoiceId = UUID.randomUUID().toString(); + String fiscalYearId = UUID.randomUUID().toString(); + String fundId1 = UUID.randomUUID().toString(); + String fundId2 = UUID.randomUUID().toString(); + String orderId = UUID.randomUUID().toString(); + String poLineId = UUID.randomUUID().toString(); + Invoice invoice = new Invoice() + .withId(invoiceId) + .withFiscalYearId(fiscalYearId) + .withStatus(Invoice.Status.PAID); + FundDistribution invoiceLineFundDistribution = new FundDistribution() + .withFundId(fundId1); + InvoiceLine invoiceLine = new InvoiceLine() + .withId(UUID.randomUUID().toString()) + .withInvoiceId(invoiceId) + .withFundDistributions(List.of(invoiceLineFundDistribution)) + .withPoLineId(poLineId) + .withReleaseEncumbrance(true); + List invoiceLines = List.of(invoiceLine); + Budget budget = new Budget() + .withId(UUID.randomUUID().toString()) + .withBudgetStatus(Budget.BudgetStatus.ACTIVE); + var poLineFundDistribution = new org.folio.rest.acq.model.orders.FundDistribution() + .withFundId(fundId2); + PurchaseOrder order = new PurchaseOrder() + .withId(orderId) + .withWorkflowStatus(PurchaseOrder.WorkflowStatus.OPEN); + PoLine poLine = new PoLine() + .withId(poLineId) + .withPurchaseOrderId(orderId) + .withFundDistribution(List.of(poLineFundDistribution)); + + when(restClient.get(any(RequestEntry.class), eq(BudgetCollection.class), eq(requestContext))) + .thenReturn(succeededFuture(new BudgetCollection().withBudgets(List.of(budget)).withTotalRecords(1))) + .thenReturn(succeededFuture(new BudgetCollection().withBudgets(List.of()).withTotalRecords(0))); + when(restClient.get(any(RequestEntry.class), eq(PoLineCollection.class), eq(requestContext))) + .thenReturn(succeededFuture(new PoLineCollection().withPoLines(List.of(poLine)).withTotalRecords(1))); + when(restClient.get(any(RequestEntry.class), eq(PurchaseOrderCollection.class), eq(requestContext))) + .thenReturn(succeededFuture(new PurchaseOrderCollection().withPurchaseOrders(List.of(order)).withTotalRecords(1))); + + Future future = cancelService.cancelInvoice(invoice, invoiceLines, null, requestContext); + assertTrue(future.failed()); + var exception = (HttpException)future.cause(); + assertEquals(404, exception.getCode()); + assertEquals(BUDGET_NOT_FOUND_USING_FISCAL_YEAR_ID.getDescription(), exception.getMessage()); + List parameters = exception.getErrors().getErrors().getFirst().getParameters(); + assertEquals(fundId2, parameters.getFirst().getValue()); + assertEquals(fiscalYearId, parameters.get(1).getValue()); + vertxTestContext.completeNow(); + } + @Test public void errorUnreleasingEncumbrances(VertxTestContext vertxTestContext) throws IOException { Invoice invoice = getMockAs(APPROVED_INVOICE_SAMPLE_PATH, Invoice.class);