diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index e2f8e4d3..4a64b5bb 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -470,6 +470,11 @@ "methods": ["GET"], "pathPattern": "/finance-storage/finance-data", "permissionsRequired": ["finance-storage.finance-data.collection.get"] + }, + { + "methods": ["PUT"], + "pathPattern": "/finance-storage/finance-data", + "permissionsRequired": ["finance-storage.finance-data.collection.put"] } ] }, @@ -1018,8 +1023,22 @@ }, { "permissionName": "finance-storage.finance-data.collection.get", - "displayName": "all finance-data for fiscal year", - "description": "Get collection of finance data for particular fiscal year" + "displayName": "all finance-data", + "description": "Get collection of finance data" + }, + { + "permissionName": "finance-storage.finance-data.collection.put", + "displayName": "Update finance-data as a bulk", + "description": "Update collection of finance data" + }, + { + "permissionName": "finance-storage.finance-data.all", + "displayName": "All finance-data perms", + "description": "All permissions for the finance data", + "subPermissions": [ + "finance-storage.finance-data.collection.get", + "finance-storage.finance-data.collection.put" + ] }, { "permissionName" : "finance.module.all", @@ -1036,7 +1055,7 @@ "finance-storage.transactions.all", "finance-storage.fund-types.all", "finance-storage.fund-update-logs.all", - "finance-storage.finance-data.collection.get" + "finance-storage.finance-data.all" ] } ], diff --git a/ramls/acq-models b/ramls/acq-models index c06778a4..0d8f736a 160000 --- a/ramls/acq-models +++ b/ramls/acq-models @@ -1 +1 @@ -Subproject commit c06778a484802db30023f1e5388a6d2972606ae1 +Subproject commit 0d8f736a3f2b5401a5cfad20a95bd831843f77a6 diff --git a/ramls/finance-data.raml b/ramls/finance-data.raml index bd605d0c..f3eeabe8 100644 --- a/ramls/finance-data.raml +++ b/ramls/finance-data.raml @@ -15,7 +15,7 @@ types: pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$ traits: - pageable: !include raml-util/traits/pageable.raml + pageable: !include raml-util/traits/pageable.raml searchable: !include raml-util/traits/searchable.raml validate: !include raml-util/traits/validation.raml @@ -33,3 +33,35 @@ resourceTypes: searchable: { description: "with valid searchable fields: for example fiscalYearId", example: "[\"fiscalYearId\", \"7a4c4d30-3b63-4102-8e2d-3ee5792d7d02\", \"=\"]" }, pageable ] + put: + description: Update finance, budget as a bulk + is: [ validate ] + body: + application/json: + type: fy-finance-data-collection + example: !include acq-models/mod-finance/examples/fy_finance_data_collection.sample + responses: + 204: + description: "Items successfully updated" + 404: + description: "One or more items not found" + body: + text/plain: + example: | + "One or more items not found" + 400: + description: "Bad request, e.g. malformed request body or query parameter. Details of the error (e.g. name of the parameter or line/character number with malformed data) provided in the response." + body: + text/plain: + example: | + "unable to update items -- malformed JSON at 13:4" + 409: + description: "Optimistic locking version conflict" + body: + text/plain: + example: "version conflict" + 500: + description: "Internal server error, e.g. due to misconfiguration" + body: + text/plain: + example: "internal server error, contact administrator" diff --git a/src/main/java/org/folio/config/ServicesConfiguration.java b/src/main/java/org/folio/config/ServicesConfiguration.java index a6943119..5975ff43 100644 --- a/src/main/java/org/folio/config/ServicesConfiguration.java +++ b/src/main/java/org/folio/config/ServicesConfiguration.java @@ -21,6 +21,7 @@ import org.folio.service.budget.BudgetService; import org.folio.service.budget.RolloverBudgetExpenseClassTotalsService; import org.folio.service.email.EmailService; +import org.folio.service.financedata.FinanceDataService; import org.folio.service.fiscalyear.FiscalYearService; import org.folio.service.fund.FundService; import org.folio.service.fund.StorageFundService; @@ -164,4 +165,9 @@ RolloverBudgetExpenseClassTotalsService rolloverBudgetExpenseClassTotalsService( TemporaryEncumbranceService temporaryEncumbranceService) { return new RolloverBudgetExpenseClassTotalsService(budgetExpenseClassService, temporaryEncumbranceService); } + + @Bean + public FinanceDataService financeDataService(FundService fundService, BudgetService budgetService) { + return new FinanceDataService(fundService, budgetService); + } } diff --git a/src/main/java/org/folio/dao/budget/BudgetDAO.java b/src/main/java/org/folio/dao/budget/BudgetDAO.java index 6a0686d0..045cd7c6 100644 --- a/src/main/java/org/folio/dao/budget/BudgetDAO.java +++ b/src/main/java/org/folio/dao/budget/BudgetDAO.java @@ -17,7 +17,7 @@ public interface BudgetDAO { Future> getBudgetsBySql(String sql, Tuple params, DBConn conn); - Future> getBudgets(Criterion criterion, DBConn conn); + Future> getBudgetsByCriterion(Criterion criterion, DBConn conn); Future getBudgetById(String id, DBConn conn); diff --git a/src/main/java/org/folio/dao/budget/BudgetPostgresDAO.java b/src/main/java/org/folio/dao/budget/BudgetPostgresDAO.java index 8fd8dc93..f44f3a98 100644 --- a/src/main/java/org/folio/dao/budget/BudgetPostgresDAO.java +++ b/src/main/java/org/folio/dao/budget/BudgetPostgresDAO.java @@ -32,7 +32,7 @@ public Future updateBatchBudgets(List budgets, DBConn conn) { List ids = budgets.stream().map(Budget::getId).toList(); logger.debug("Trying update batch budgets, ids={}", ids); return conn.updateBatch(BUDGET_TABLE, budgets) - .onSuccess(rowSet -> logger.info("Updated {} batch budgets", budgets.size())) + .onSuccess(rowSet -> logger.info("updateBatchBudgets:: Updated {} batch budgets", budgets.size())) .onFailure(e -> logger.error("Update batch budgets by failed, ids={}", ids, e)) .mapEmpty(); } @@ -42,7 +42,7 @@ public Future updateBatchBudgetsBySql(String sql, DBConn conn) { logger.debug("Trying update batch budgets by query: {}", sql); return conn.execute(sql) .map(SqlResult::rowCount) - .onSuccess(rowCount -> logger.info("Updated {} batch budgets", rowCount)) + .onSuccess(rowCount -> logger.info("updateBatchBudgetsBySql:: Updated {} batch budgets", rowCount)) .onFailure(e -> logger.error("Update batch budgets by query: {} failed", sql, e)); } @@ -67,7 +67,7 @@ public Future> getBudgetsBySql(String sql, Tuple params, DBConn con * @param conn : db connection */ @Override - public Future> getBudgets(Criterion criterion, DBConn conn) { + public Future> getBudgetsByCriterion(Criterion criterion, DBConn conn) { logger.debug("Trying to get budgets by query: {}", criterion); return conn.get(BUDGET_TABLE, Budget.class, criterion, false) .map(results -> { diff --git a/src/main/java/org/folio/dao/fund/FundDAO.java b/src/main/java/org/folio/dao/fund/FundDAO.java index 9be94b36..fd1c7001 100644 --- a/src/main/java/org/folio/dao/fund/FundDAO.java +++ b/src/main/java/org/folio/dao/fund/FundDAO.java @@ -10,4 +10,8 @@ public interface FundDAO { Future getFundById(String id, DBConn conn); Future> getFundsByIds(List ids, DBConn conn); + Future isFundStatusChanged(Fund fund, DBConn conn); + Future updateRelatedCurrentFYBudgets(Fund fund, DBConn conn); + Future updateFund(Fund fund, DBConn conn); + Future updateFunds(List funds, DBConn conn); } diff --git a/src/main/java/org/folio/dao/fund/FundPostgresDAO.java b/src/main/java/org/folio/dao/fund/FundPostgresDAO.java index b12390d6..1a13360d 100644 --- a/src/main/java/org/folio/dao/fund/FundPostgresDAO.java +++ b/src/main/java/org/folio/dao/fund/FundPostgresDAO.java @@ -1,10 +1,15 @@ package org.folio.dao.fund; +import static org.folio.rest.impl.BudgetAPI.BUDGET_TABLE; +import static org.folio.rest.impl.FiscalYearAPI.FISCAL_YEAR_TABLE; import static org.folio.rest.impl.FundAPI.FUND_TABLE; +import static org.folio.rest.persist.HelperUtils.getFullTableName; +import io.vertx.sqlclient.Tuple; import java.util.Collections; import java.util.List; +import java.util.UUID; import org.folio.rest.exception.HttpException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -21,6 +26,12 @@ public class FundPostgresDAO implements FundDAO { private static final Logger logger = LogManager.getLogger(); private static final String FUND_NOT_FOUND = "Fund not found, id=%s"; + private static final String QUERY_UPDATE_CURRENT_FY_BUDGET = + "UPDATE %s SET jsonb = jsonb_set(jsonb,'{budgetStatus}', $1) " + + "WHERE((fundId=$2) " + + "AND (budget.fiscalYearId IN " + + "(SELECT id FROM %s WHERE current_date between (jsonb->>'periodStart')::timestamp " + + "AND (jsonb->>'periodEnd')::timestamp)));"; @Override public Future getFundById(String id, DBConn conn) { @@ -49,6 +60,39 @@ public Future> getFundsByIds(List ids, DBConn conn) { return getFundsByCriterion(criterionBuilder.build(), conn); } + @Override + public Future isFundStatusChanged(Fund fund, DBConn conn) { + return getFundById(fund.getId(), conn) + .map(existingFund -> existingFund.getFundStatus() != fund.getFundStatus()); + } + + @Override + public Future updateRelatedCurrentFYBudgets(Fund fund, DBConn conn) { + String fullBudgetTableName = getFullTableName(conn.getTenantId(), BUDGET_TABLE); + String fullFYTableName = getFullTableName(conn.getTenantId(), FISCAL_YEAR_TABLE); + String updateQuery = String.format(QUERY_UPDATE_CURRENT_FY_BUDGET, fullBudgetTableName, fullFYTableName); + + return conn.execute(updateQuery, Tuple.of(fund.getFundStatus().value(), UUID.fromString(fund.getId()))) + .mapEmpty(); + } + + @Override + public Future updateFund(Fund fund, DBConn conn) { + logger.debug("Trying to update finance storage fund by id {}", fund.getId()); + return conn.update(FUND_TABLE, fund, fund.getId()) + .onSuccess(x -> logger.info("Fund record '{}' was successfully updated", fund.getId())) + .mapEmpty(); + } + + @Override + public Future updateFunds(List funds, DBConn conn) { + List fundIds = funds.stream().map(Fund::getId).toList(); + logger.debug("Trying to update finance storage funds: '{}'", fundIds); + return conn.updateBatch(FUND_TABLE, funds) + .onSuccess(x -> logger.info("Funds '{}' was successfully updated", fundIds)) + .mapEmpty(); + } + private Future> getFundsByCriterion(Criterion criterion, DBConn conn) { logger.debug("Trying to get funds by criterion = {}", criterion); return conn.get(FUND_TABLE, Fund.class, criterion, false) diff --git a/src/main/java/org/folio/rest/impl/FinanceDataApi.java b/src/main/java/org/folio/rest/impl/FinanceDataApi.java index 44d4ec39..c91865c9 100644 --- a/src/main/java/org/folio/rest/impl/FinanceDataApi.java +++ b/src/main/java/org/folio/rest/impl/FinanceDataApi.java @@ -1,21 +1,37 @@ package org.folio.rest.impl; +import static io.vertx.core.Future.succeededFuture; +import static org.folio.rest.jaxrs.resource.FinanceStorageFinanceData.PutFinanceStorageFinanceDataResponse.respond204; + import javax.ws.rs.core.Response; import java.util.Map; import io.vertx.core.AsyncResult; import io.vertx.core.Context; import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import org.folio.rest.core.model.RequestContext; import org.folio.rest.jaxrs.model.FyFinanceData; import org.folio.rest.jaxrs.model.FyFinanceDataCollection; import org.folio.rest.jaxrs.resource.FinanceStorageFinanceData; import org.folio.rest.persist.PgUtil; +import org.folio.rest.util.ResponseUtils; +import org.folio.service.financedata.FinanceDataService; +import org.folio.spring.SpringContextUtil; +import org.springframework.beans.factory.annotation.Autowired; public class FinanceDataApi implements FinanceStorageFinanceData { private static final String FINANCE_DATA_VIEW = "finance_data_view"; + @Autowired + private FinanceDataService financeDataService; + + public FinanceDataApi() { + SpringContextUtil.autowireDependencies(this, Vertx.currentContext()); + } + @Override public void getFinanceStorageFinanceData(String query, String totalRecords, int offset, int limit, Map okapiHeaders, Handler> asyncResultHandler, Context vertxContext) { @@ -23,4 +39,11 @@ public void getFinanceStorageFinanceData(String query, String totalRecords, int okapiHeaders, vertxContext, GetFinanceStorageFinanceDataResponse.class, asyncResultHandler); } + @Override + public void putFinanceStorageFinanceData(FyFinanceDataCollection entity, Map okapiHeaders, + Handler> asyncResultHandler, Context vertxContext) { + financeDataService.update(entity, new RequestContext(vertxContext, okapiHeaders)) + .onSuccess(v -> asyncResultHandler.handle(succeededFuture(respond204()))) + .onFailure(ResponseUtils::handleFailure); + } } diff --git a/src/main/java/org/folio/rest/impl/FundAPI.java b/src/main/java/org/folio/rest/impl/FundAPI.java index 1ec16596..246fc51a 100644 --- a/src/main/java/org/folio/rest/impl/FundAPI.java +++ b/src/main/java/org/folio/rest/impl/FundAPI.java @@ -1,34 +1,28 @@ package org.folio.rest.impl; import static io.vertx.core.Future.succeededFuture; -import static org.folio.rest.impl.BudgetAPI.BUDGET_TABLE; -import static org.folio.rest.impl.FiscalYearAPI.FISCAL_YEAR_TABLE; -import static org.folio.rest.persist.HelperUtils.getFullTableName; -import static org.folio.rest.util.ResponseUtils.handleFailure; -import static org.folio.rest.util.ResponseUtils.handleNoContentResponse; - -import java.util.Map; -import java.util.UUID; +import static org.folio.rest.jaxrs.resource.FinanceStorageGroupFundFiscalYears.PutFinanceStorageGroupFundFiscalYearsByIdResponse.respond204; import javax.ws.rs.core.Response; +import java.util.Map; -import org.folio.rest.exception.HttpException; +import io.vertx.core.AsyncResult; +import io.vertx.core.Context; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.folio.rest.annotations.Validate; +import org.folio.rest.core.model.RequestContext; +import org.folio.rest.exception.HttpException; import org.folio.rest.jaxrs.model.Fund; import org.folio.rest.jaxrs.model.FundCollection; import org.folio.rest.jaxrs.resource.FinanceStorageFunds; -import org.folio.rest.persist.DBClient; -import org.folio.rest.persist.DBConn; import org.folio.rest.persist.HelperUtils; import org.folio.rest.persist.PgUtil; -import io.vertx.core.AsyncResult; -import io.vertx.core.Context; -import io.vertx.core.Future; -import io.vertx.core.Handler; -import io.vertx.core.Promise; -import org.apache.logging.log4j.Logger; -import io.vertx.sqlclient.Tuple; +import org.folio.service.fund.FundService; +import org.folio.spring.SpringContextUtil; +import org.springframework.beans.factory.annotation.Autowired; public class FundAPI implements FinanceStorageFunds { @@ -36,6 +30,13 @@ public class FundAPI implements FinanceStorageFunds { public static final String FUND_TABLE = "fund"; + @Autowired + private FundService fundService; + + public FundAPI() { + SpringContextUtil.autowireDependencies(this, Vertx.currentContext()); + } + @Override @Validate public void getFinanceStorageFunds(String query, String totalRecords, int offset, int limit, Map okapiHeaders, Handler> asyncResultHandler, Context vertxContext) { @@ -66,67 +67,14 @@ public void deleteFinanceStorageFundsById(String id, Map okapiHe public void putFinanceStorageFundsById(String id, Fund fund, Map okapiHeaders, Handler> asyncResultHandler, Context vertxContext) { logger.debug("Trying to update finance storage fund by id {}", id); fund.setId(id); - DBClient client = new DBClient(vertxContext, okapiHeaders); vertxContext.runOnContext(event -> - isFundStatusChanged(fund, client) - .onComplete(result -> { - if (result.failed()) { - HttpException cause = (HttpException) result.cause(); - logger.error("Failed to update the finance storage fund with Id {}", fund.getId(), cause); - HelperUtils.replyWithErrorResponse(asyncResultHandler, cause); - } else if (result.result() == null) { - logger.warn("Finance storage fund with id {} not found", id); - asyncResultHandler.handle(succeededFuture(FinanceStorageFunds.PutFinanceStorageFundsByIdResponse.respond404WithTextPlain("Not found"))); - } else if (Boolean.TRUE.equals(result.result())) { - handleFundStatusUpdate(fund, client).onComplete(asyncResultHandler); - } else { - PgUtil.put(FUND_TABLE, fund, id, okapiHeaders, vertxContext, FinanceStorageFunds.PutFinanceStorageFundsByIdResponse.class, asyncResultHandler); - } - }) + fundService.updateFund(fund, new RequestContext(vertxContext, okapiHeaders)) + .onSuccess(result -> asyncResultHandler.handle(succeededFuture(respond204()))) + .onFailure(throwable -> { + HttpException cause = (HttpException) throwable; + logger.error("Failed to update the finance storage fund with Id {}", fund.getId(), cause); + HelperUtils.replyWithErrorResponse(asyncResultHandler, cause); + }) ); } - - private Future handleFundStatusUpdate(Fund fund, DBClient client) { - return client.withTrans(conn -> - updateRelatedCurrentFYBudgets(fund, conn) - .compose(v -> updateFund(fund, conn)) - ) - .transform(result -> handleNoContentResponse(result, fund.getId(), "Fund {} {} updated")); - } - - private Future isFundStatusChanged(Fund fund, DBClient client) { - Promise promise = Promise.promise(); - client.getPgClient().getById(FUND_TABLE, fund.getId(), Fund.class, event -> { - if (event.failed()) { - handleFailure(promise, event); - } else { - if (event.result() != null) { - promise.complete(event.result().getFundStatus() != fund.getFundStatus()); - } else { - promise.complete(null); - } - } - }); - return promise.future(); - } - - private Future updateFund(Fund fund, DBConn conn) { - return conn.update(FUND_TABLE, fund, fund.getId()) - .onSuccess(x -> logger.info("Fund record {} was successfully updated", fund)) - .mapEmpty(); - } - - private Future updateRelatedCurrentFYBudgets(Fund fund, DBConn conn) { - String fullBudgetTableName = getFullTableName(conn.getTenantId(), BUDGET_TABLE); - String fullFYTableName = getFullTableName(conn.getTenantId(), FISCAL_YEAR_TABLE); - - String sql = "UPDATE "+ fullBudgetTableName +" SET jsonb = jsonb_set(jsonb,'{budgetStatus}', $1) " + - "WHERE((fundId=$2) " + - "AND (budget.fiscalYearId IN " + - "(SELECT id FROM " + fullFYTableName + " WHERE current_date between (jsonb->>'periodStart')::timestamp " + - "AND (jsonb->>'periodEnd')::timestamp)));"; - - return conn.execute(sql, Tuple.of(fund.getFundStatus().value(), UUID.fromString(fund.getId()))) - .mapEmpty(); - } } diff --git a/src/main/java/org/folio/service/budget/BudgetService.java b/src/main/java/org/folio/service/budget/BudgetService.java index fb5da7bb..5e58c0d6 100644 --- a/src/main/java/org/folio/service/budget/BudgetService.java +++ b/src/main/java/org/folio/service/budget/BudgetService.java @@ -16,6 +16,7 @@ import org.folio.dao.budget.BudgetDAO; import org.folio.rest.jaxrs.model.Budget; import org.folio.rest.jaxrs.model.LedgerFiscalYearRollover; +import org.folio.rest.persist.CriterionBuilder; import org.folio.rest.persist.DBClient; import org.folio.rest.persist.DBConn; import org.folio.utils.CalculationUtils; @@ -64,6 +65,12 @@ public Future> getBudgets(String sql, Tuple params, DBConn conn) { return budgetDAO.getBudgetsBySql(sql, params, conn); } + public Future> getBudgetsByIds(List ids, DBConn conn) { + CriterionBuilder criterionBuilder = new CriterionBuilder("OR"); + ids.forEach(id -> criterionBuilder.with("id", id)); + return budgetDAO.getBudgetsByCriterion(criterionBuilder.build(), conn); + } + public void clearReadOnlyFields(Budget budgetFromNew) { budgetFromNew.setAllocated(null); budgetFromNew.setAvailable(null); diff --git a/src/main/java/org/folio/service/financedata/FinanceDataService.java b/src/main/java/org/folio/service/financedata/FinanceDataService.java new file mode 100644 index 00000000..66d11a2e --- /dev/null +++ b/src/main/java/org/folio/service/financedata/FinanceDataService.java @@ -0,0 +1,102 @@ +package org.folio.service.financedata; + +import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; + +import java.util.List; + +import io.vertx.core.Future; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.okapi.common.GenericCompositeFuture; +import org.folio.rest.core.model.RequestContext; +import org.folio.rest.jaxrs.model.Budget; +import org.folio.rest.jaxrs.model.Fund; +import org.folio.rest.jaxrs.model.FyFinanceData; +import org.folio.rest.jaxrs.model.FyFinanceDataCollection; +import org.folio.rest.jaxrs.model.Tags; +import org.folio.rest.persist.DBConn; +import org.folio.service.budget.BudgetService; +import org.folio.service.fund.FundService; + +public class FinanceDataService { + private static final Logger logger = LogManager.getLogger(); + + private final FundService fundService; + private final BudgetService budgetService; + + public FinanceDataService(FundService fundService, BudgetService budgetService) { + this.fundService = fundService; + this.budgetService = budgetService; + } + + public Future update(FyFinanceDataCollection entity, RequestContext requestContext) { + if (CollectionUtils.isEmpty(entity.getFyFinanceData())) { + return Future.succeededFuture(); + } + var dbClient = requestContext.toDBClient(); + return dbClient + .withTrans(conn -> { + var updateFundFuture = processFundUpdate(entity, conn); + var updateBudgetFuture = processBudgetUpdate(entity, conn); + return GenericCompositeFuture.all(List.of(updateFundFuture, updateBudgetFuture)); + }) + .onSuccess(v -> logger.info("Successfully updated finance data")) + .onFailure(e -> logger.error("Failed to update finance data", e)) + .mapEmpty(); + } + + private Future processFundUpdate(FyFinanceDataCollection entity, DBConn conn) { + List fundIds = entity.getFyFinanceData().stream() + .map(FyFinanceData::getFundId) + .toList(); + return fundService.getFundsByIds(fundIds, conn) + .map(funds -> setNewValuesForFunds(funds, entity)) + .compose(funds -> fundService.updateFunds(funds, conn)); + } + + private Future processBudgetUpdate(FyFinanceDataCollection entity, DBConn conn) { + List budgetIds = entity.getFyFinanceData().stream() + .map(FyFinanceData::getBudgetId) + .toList(); + return budgetService.getBudgetsByIds(budgetIds, conn) + .map(budgets -> setNewValuesForBudgets(budgets, entity)) + .compose(budgets -> budgetService.updateBatchBudgets(budgets, conn)); + } + + private List setNewValuesForFunds(List funds, FyFinanceDataCollection entity) { + return funds.stream() + .map(fund -> setNewValues(fund, entity)) + .toList(); + } + + private Fund setNewValues(Fund fund, FyFinanceDataCollection entity) { + var fundFinanceData = entity.getFyFinanceData().stream() + .filter(data -> data.getFundId().equals(fund.getId())) + .findFirst() + .orElseThrow(); + + fund.setDescription(fundFinanceData.getFundDescription()); + if (fundFinanceData.getFundTags() != null && isNotEmpty(fundFinanceData.getFundTags().getTagList())) { + fund.setTags(new Tags().withTagList(fundFinanceData.getFundTags().getTagList())); + } + return fund; + } + + private List setNewValuesForBudgets(List budgets, FyFinanceDataCollection entity) { + return budgets.stream() + .map(budget -> setNewValues(budget, entity)) + .toList(); + } + + private Budget setNewValues(Budget budget, FyFinanceDataCollection entity) { + var budgetFinanceData = entity.getFyFinanceData().stream() + .filter(data -> data.getBudgetId().equals(budget.getId())) + .findFirst() + .orElseThrow(); + + return budget.withBudgetStatus(Budget.BudgetStatus.fromValue(budgetFinanceData.getBudgetStatus().value())) + .withAllowableExpenditure(budgetFinanceData.getBudgetAllowableExpenditure()) + .withAllowableEncumbrance(budgetFinanceData.getBudgetAllowableEncumbrance()); + } +} diff --git a/src/main/java/org/folio/service/fund/FundService.java b/src/main/java/org/folio/service/fund/FundService.java index a13c6aba..9988c2f9 100644 --- a/src/main/java/org/folio/service/fund/FundService.java +++ b/src/main/java/org/folio/service/fund/FundService.java @@ -1,6 +1,7 @@ package org.folio.service.fund; import io.vertx.core.Future; +import org.folio.rest.core.model.RequestContext; import org.folio.rest.jaxrs.model.Fund; import org.folio.rest.persist.DBConn; @@ -9,4 +10,6 @@ public interface FundService { Future getFundById(String fundId, DBConn conn); Future> getFundsByIds(List ids, DBConn conn); + Future updateFund(Fund fund, RequestContext requestContext); + Future updateFunds(List fund, DBConn conn); } diff --git a/src/main/java/org/folio/service/fund/StorageFundService.java b/src/main/java/org/folio/service/fund/StorageFundService.java index a8bd387b..eeb757d3 100644 --- a/src/main/java/org/folio/service/fund/StorageFundService.java +++ b/src/main/java/org/folio/service/fund/StorageFundService.java @@ -1,13 +1,17 @@ package org.folio.service.fund; import io.vertx.core.Future; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.folio.dao.fund.FundDAO; +import org.folio.rest.core.model.RequestContext; import org.folio.rest.jaxrs.model.Fund; import org.folio.rest.persist.DBConn; import java.util.List; public class StorageFundService implements FundService { + private static final Logger logger = LogManager.getLogger(); private final FundDAO fundDAO; @@ -24,4 +28,27 @@ public Future getFundById(String fundId, DBConn conn) { public Future> getFundsByIds(List ids, DBConn conn) { return fundDAO.getFundsByIds(ids, conn); } + + @Override + public Future updateFund(Fund fund, RequestContext requestContext) { + logger.debug("Trying to update fund '{}'", fund.getId()); + var dbClient = requestContext.toDBClient(); + return dbClient.withTrans(conn -> + fundDAO.isFundStatusChanged(fund, conn) + .compose(statusChanged -> { + if (Boolean.TRUE.equals(statusChanged)) { + logger.info("updateFund:: Fund '{}' status has been changed to '{}'", fund.getId(), fund.getFundStatus()); + return fundDAO.updateRelatedCurrentFYBudgets(fund, conn) + .compose(v -> fundDAO.updateFund(fund, conn)); + } + return fundDAO.updateFund(fund, conn); + }) + ); + } + + @Override + public Future updateFunds(List funds, DBConn conn) { + logger.debug("updateFundsWithMinChange:: Trying to update '{}' fund(s) with minimal changes", funds.size()); + return fundDAO.updateFunds(funds, conn); + } } diff --git a/src/main/resources/templates/db_scripts/all_finance_data_view.sql b/src/main/resources/templates/db_scripts/all_finance_data_view.sql index 9c77b4c3..4c6492a9 100644 --- a/src/main/resources/templates/db_scripts/all_finance_data_view.sql +++ b/src/main/resources/templates/db_scripts/all_finance_data_view.sql @@ -9,8 +9,10 @@ SELECT 'fundName', fund.jsonb ->>'name', 'fundDescription', fund.jsonb ->>'description', 'fundStatus', fund.jsonb ->>'fundStatus', - 'fundTags', fund.jsonb ->'tags' -> 'tagList', + 'fundTags', jsonb_build_object('tagList', fund.jsonb -> 'tags' -> 'tagList'), 'fundAcqUnitIds', fund.jsonb ->'acqUnitIds', + 'ledgerId', ledger.id, + 'ledgerCode', ledger.jsonb ->> 'code', 'budgetId', budget.id, 'budgetName', budget.jsonb ->>'name', 'budgetStatus', budget.jsonb ->>'budgetStatus', @@ -18,12 +20,18 @@ SELECT 'budgetCurrentAllocation', budget.jsonb ->>'allocated', 'budgetAllowableExpenditure', budget.jsonb ->>'allowableExpenditure', 'budgetAllowableEncumbrance', budget.jsonb ->>'allowableEncumbrance', - 'budgetAcqUnitIds', budget.jsonb ->'acqUnitIds' + 'budgetAcqUnitIds', budget.jsonb ->'acqUnitIds', + 'groupId', groups.id, + 'groupCode', groups.jsonb ->> 'code' ) as jsonb FROM ${myuniversity}_${mymodule}.fiscal_year LEFT OUTER JOIN ${myuniversity}_${mymodule}.ledger - ON ledger.fiscalyearoneid = fiscal_year.id + ON ledger.fiscalyearoneid = fiscal_year.id LEFT OUTER JOIN ${myuniversity}_${mymodule}.fund - ON fund.ledgerid = ledger.id + ON fund.ledgerid = ledger.id LEFT OUTER JOIN ${myuniversity}_${mymodule}.budget - ON fund.id = budget.fundid; + ON fund.id = budget.fundid +LEFT OUTER JOIN ${myuniversity}_${mymodule}.group_fund_fiscal_year + ON fund.id = group_fund_fiscal_year.fundid +LEFT OUTER JOIN ${myuniversity}_${mymodule}.groups + ON group_fund_fiscal_year.groupid = groups.id; diff --git a/src/test/java/org/folio/StorageTestSuite.java b/src/test/java/org/folio/StorageTestSuite.java index 6df6d059..75b54aa1 100644 --- a/src/test/java/org/folio/StorageTestSuite.java +++ b/src/test/java/org/folio/StorageTestSuite.java @@ -36,6 +36,7 @@ import org.folio.rest.tools.utils.NetworkUtils; import org.folio.rest.utils.DBClientTest; import org.folio.service.email.EmailServiceTest; +import org.folio.service.fianancedata.FinanceDataServiceTest; import org.folio.service.rollover.LedgerRolloverServiceTest; import org.folio.service.rollover.RolloverProgressServiceTest; import org.folio.service.rollover.RolloverValidationServiceTest; @@ -220,4 +221,7 @@ class PendingPaymentTestNested extends PendingPaymentTest {} @Nested class FinanceDataApiTestNested extends FinanceDataApiTest {} + + @Nested + class FinanceDataServiceTestNested extends FinanceDataServiceTest {} } diff --git a/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java b/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java index a57733a6..8745e889 100644 --- a/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java +++ b/src/test/java/org/folio/rest/impl/FinanceDataApiTest.java @@ -21,6 +21,8 @@ import org.folio.rest.jaxrs.model.Budget; import org.folio.rest.jaxrs.model.FiscalYear; import org.folio.rest.jaxrs.model.Fund; +import org.folio.rest.jaxrs.model.FundTags; +import org.folio.rest.jaxrs.model.FyFinanceData; import org.folio.rest.jaxrs.model.FyFinanceDataCollection; import org.folio.rest.jaxrs.model.Ledger; import org.folio.rest.jaxrs.model.TenantJob; @@ -37,6 +39,7 @@ public class FinanceDataApiTest extends TestBase { private static final Logger logger = LogManager.getLogger(); private static final String FINANCE_DATA_ENDPOINT = HelperUtils.getEndpoint(FinanceStorageFinanceData.class); private static TenantJob tenantJob; + private static final String FISCAL_YEAR_ID = UUID.randomUUID().toString(); @BeforeAll public static void before() { @@ -78,42 +81,86 @@ public void positive_testGetQuery() { @Test public void positive_testResponseOfGetWithParamsFiscalYearAndAcqUnitIds() { - var fiscalYearId = UUID.randomUUID().toString(); var acqUnitId = UUID.randomUUID().toString(); var fiscalYearAcqUnitEndpoint = String.format("%s?query=(fiscalYearId==%s and fundAcqUnitIds=%s and budgetAcqUnitIds=%s)", - FINANCE_DATA_ENDPOINT, fiscalYearId, acqUnitId, acqUnitId); - createMockData(fiscalYearId, acqUnitId); + FINANCE_DATA_ENDPOINT, FISCAL_YEAR_ID, acqUnitId, acqUnitId); + createMockData(FISCAL_YEAR_ID, acqUnitId, "FY2088", "first"); var response = getData(fiscalYearAcqUnitEndpoint, TENANT_HEADER); var body = response.getBody().as(FyFinanceDataCollection.class); var actualFyFinanceData = body.getFyFinanceData().get(0); - assertEquals(fiscalYearId, actualFyFinanceData.getFiscalYearId()); + assertEquals(FISCAL_YEAR_ID, actualFyFinanceData.getFiscalYearId()); assertEquals(acqUnitId, actualFyFinanceData.getFundAcqUnitIds().get(0)); assertEquals(acqUnitId, actualFyFinanceData.getBudgetAcqUnitIds().get(0)); } - private void createMockData(String fiscalYearId, String acqUnitId) { + @Test + public void positive_testUpdateFinanceData() { + var fiscalYearId = UUID.randomUUID().toString(); + var acqUnitId = UUID.randomUUID().toString(); + var expectedDescription = "UPDATED Description"; + var expectedTags = List.of("New tag"); + var expectedBudgetStatus = FyFinanceData.BudgetStatus.INACTIVE; + var expectedNumber = 200.0; + createMockData(fiscalYearId, acqUnitId, "FY2099", "second"); + + var response = getData(FINANCE_DATA_ENDPOINT + "?query=(fiscalYearId==" + fiscalYearId + ")", TENANT_HEADER); + var body = response.getBody().as(FyFinanceDataCollection.class); + var fyFinanceData = body.getFyFinanceData().get(0); + + // Set required fields difference values than before + fyFinanceData.setFundDescription(expectedDescription); + fyFinanceData.setFundTags(new FundTags().withTagList(expectedTags)); + fyFinanceData.setBudgetStatus(expectedBudgetStatus); + fyFinanceData.setBudgetAllowableExpenditure(expectedNumber); + fyFinanceData.setBudgetAllowableEncumbrance(expectedNumber); + + var updatedCollection = new FyFinanceDataCollection().withFyFinanceData(List.of(fyFinanceData)).withTotalRecords(1); + + // Update finance data as a bulk + var updateResponse = putData(FINANCE_DATA_ENDPOINT, JsonObject.mapFrom(updatedCollection).encodePrettily(), TENANT_HEADER); + assertEquals(204, updateResponse.getStatusCode()); + + // Get updated result + var updatedResponse = getData(FINANCE_DATA_ENDPOINT + "?query=(fiscalYearId==" + fiscalYearId + ")", TENANT_HEADER); + var updatedBody = updatedResponse.getBody().as(FyFinanceDataCollection.class); + var updatedFyFinanceData = updatedBody.getFyFinanceData().get(0); + + assertEquals(expectedDescription, updatedFyFinanceData.getFundDescription()); + assertEquals(expectedTags, updatedFyFinanceData.getFundTags().getTagList()); + assertEquals(expectedNumber, updatedFyFinanceData.getBudgetAllowableEncumbrance()); + assertEquals(expectedNumber, updatedFyFinanceData.getBudgetAllowableExpenditure()); + } + + private void createMockData(String fiscalYearId, String acqUnitId, String code, String name) { var fundId = UUID.randomUUID().toString(); var ledgerId = UUID.randomUUID().toString(); var budgetId = UUID.randomUUID().toString(); var fiscalYear = new JsonObject(getFile(FISCAL_YEAR.getPathToSampleFile())).mapTo(FiscalYear.class) - .withId(fiscalYearId).withCode("FY2042"); + .withId(fiscalYearId).withCode(code); createEntity(FISCAL_YEAR.getEndpoint(), fiscalYear, TENANT_HEADER); var ledger = new JsonObject(getFile(LEDGER.getPathToSampleFile())).mapTo(Ledger.class).withId(ledgerId) - .withCode("first").withName("First Ledger").withFiscalYearOneId(fiscalYearId); + .withCode(code).withName(name).withFiscalYearOneId(fiscalYearId); createEntity(LEDGER.getEndpoint(), ledger, TENANT_HEADER); var fund = new JsonObject(getFile(FUND.getPathToSampleFile())).mapTo(Fund.class) - .withId(fundId).withCode("first").withName("first").withLedgerId(ledgerId) + .withId(fundId).withCode(code).withName(name).withLedgerId(ledgerId) .withFundTypeId(null).withAcqUnitIds(List.of(acqUnitId)); createEntity(FUND.getEndpoint(), fund, TENANT_HEADER); var budget = new JsonObject(getFile(BUDGET.getPathToSampleFile())).mapTo(Budget.class) - .withId(budgetId).withName("first").withFiscalYearId(fiscalYearId).withFundId(fundId) + .withId(budgetId).withName(name) + .withBudgetStatus(Budget.BudgetStatus.ACTIVE) + .withFiscalYearId(fiscalYearId) + .withFundId(fundId) + .withInitialAllocation(100.0) + .withAllowableExpenditure(101.0) + .withAllowableEncumbrance(102.0) .withAcqUnitIds(List.of(acqUnitId)); createEntity(BUDGET.getEndpoint(), budget, TENANT_HEADER); } + } diff --git a/src/test/java/org/folio/rest/impl/TestBase.java b/src/test/java/org/folio/rest/impl/TestBase.java index 02a1bd43..f67ab4a4 100644 --- a/src/test/java/org/folio/rest/impl/TestBase.java +++ b/src/test/java/org/folio/rest/impl/TestBase.java @@ -7,7 +7,6 @@ import static org.hamcrest.Matchers.either; import static org.hamcrest.Matchers.hasEntry; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThat; import java.io.InputStream; import java.util.Set; @@ -146,6 +145,14 @@ Response getDataById(String endpoint, String id, Header header) { .get(storageUrl(endpoint)); } + Response putData(String endpoint, String input, Header tenant) { + return given() + .header(tenant) + .contentType(ContentType.JSON) + .body(input) + .put(storageUrl(endpoint)); + } + Response putData(String endpoint, String id, String input, Header tenant) { return given() .pathParam("id", id) @@ -233,7 +240,7 @@ void testFetchingUpdatedEntity(String id, TestEntities subObject) { .path(subObject.getUpdatedFieldName()); // Get string value of updated field and compare - assertThat(String.valueOf(prop), equalTo(subObject.getUpdatedFieldValue())); + assertEquals(String.valueOf(prop), subObject.getUpdatedFieldValue()); } Response testEntitySuccessfullyFetched(String endpoint, String id) { diff --git a/src/test/java/org/folio/service/fianancedata/FinanceDataServiceTest.java b/src/test/java/org/folio/service/fianancedata/FinanceDataServiceTest.java new file mode 100644 index 00000000..1887ccef --- /dev/null +++ b/src/test/java/org/folio/service/fianancedata/FinanceDataServiceTest.java @@ -0,0 +1,192 @@ +package org.folio.service.fianancedata; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import java.util.function.Function; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import org.folio.rest.core.model.RequestContext; +import org.folio.rest.jaxrs.model.Budget; +import org.folio.rest.jaxrs.model.Fund; +import org.folio.rest.jaxrs.model.FundTags; +import org.folio.rest.jaxrs.model.FyFinanceData; +import org.folio.rest.jaxrs.model.FyFinanceDataCollection; +import org.folio.rest.persist.DBClient; +import org.folio.rest.persist.DBConn; +import org.folio.service.budget.BudgetService; +import org.folio.service.financedata.FinanceDataService; +import org.folio.service.fund.FundService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +@ExtendWith(VertxExtension.class) +public class FinanceDataServiceTest { + + @Mock + private FundService fundService; + @Mock + private BudgetService budgetService; + @Mock + private RequestContext requestContext; + @Mock + private DBClient dbClient; + @Mock + private DBConn dbConn; + + @InjectMocks + private FinanceDataService financeDataService2; + + private AutoCloseable mockitoMocks; + + @BeforeEach + void setUp() { + mockitoMocks = MockitoAnnotations.openMocks(this); + } + + @AfterEach + void tearDown() throws Exception { + mockitoMocks.close(); + } + + @Test + void shouldSuccessfullyUpdateFinanceData(VertxTestContext testContext) { + FinanceDataService financeDataService = Mockito.spy(financeDataService2); + var collection = createTestFinanceDataCollection(); + var oldFund = new Fund().withId(collection.getFyFinanceData().get(0).getFundId()) + .withName("NAME").withCode("CODE").withFundStatus(Fund.FundStatus.ACTIVE) + .withDescription("Old des"); + var oldBudget = new Budget().withId(collection.getFyFinanceData().get(0).getBudgetId()) + .withName("NAME") + .withBudgetStatus(Budget.BudgetStatus.ACTIVE); + setupMocks(oldFund, oldBudget); + + testContext.assertComplete(financeDataService.update(collection, requestContext) + .onComplete(testContext.succeeding(result -> { + testContext.verify(() -> { + verifyFundUpdates(collection); + verifyBudgetUpdates(collection); + }); + testContext.completeNow(); + }))); + } + + @Test + void shouldFailUpdateWhenFundServiceFails(VertxTestContext testContext) { + var collection = createTestFinanceDataCollection(); + var expectedError = new RuntimeException("Fund service error"); + + setupMocksForFailure(expectedError); + + financeDataService2.update(collection, requestContext) + .onComplete(testContext.failing(error -> { + testContext.verify(() -> { + assertEquals("Fund service error", error.getMessage()); + verify(budgetService, never()).updateBatchBudgets(any(), any()); + verify(fundService, never()).updateFunds(any(), any()); + }); + testContext.completeNow(); + })); + } + + private void setupMocks(Fund oldFund, Budget oldBudget) { + when(requestContext.toDBClient()).thenReturn(dbClient); + doAnswer(invocation -> { + Function> function = invocation.getArgument(0); + return function.apply(dbConn); + }).when(dbClient).withTrans(any()); + + when(fundService.getFundsByIds(any(), any())).thenReturn(Future.succeededFuture(List.of(oldFund))); + when(fundService.updateFunds(any(), any())).thenReturn(Future.succeededFuture()); + when(budgetService.getBudgetsByIds(any(), any())).thenReturn(Future.succeededFuture(List.of(oldBudget))); + when(budgetService.updateBatchBudgets(any(), any())).thenReturn(Future.succeededFuture()); + } + + private void setupMocksForFailure(RuntimeException expectedError) { + when(requestContext.toDBClient()).thenReturn(dbClient); + doAnswer(invocation -> { + Function> function = invocation.getArgument(0); + return function.apply(dbConn); + }).when(dbClient).withTrans(any()); + + when(fundService.getFundsByIds(any(), any())).thenReturn(Future.failedFuture(expectedError)); + when(budgetService.getBudgetsByIds(any(), any())).thenReturn(Future.succeededFuture()); + } + + private void verifyFundUpdates(FyFinanceDataCollection collection) { + ArgumentCaptor> fundIdsCaptor = ArgumentCaptor.forClass(List.class); + verify(fundService).getFundsByIds(fundIdsCaptor.capture(), eq(dbConn)); + assertEquals(collection.getFyFinanceData().get(0).getFundId(), fundIdsCaptor.getValue().get(0)); + + ArgumentCaptor> fundCaptor = ArgumentCaptor.forClass(List.class); + verify(fundService).updateFunds(fundCaptor.capture(), eq(dbConn)); + Fund updatedFund = fundCaptor.getValue().get(0); + + assertNotEquals("CODE CHANGED", updatedFund.getCode()); + assertNotEquals("NAME CHANGED", updatedFund.getName()); + + assertEquals(Fund.FundStatus.ACTIVE, updatedFund.getFundStatus()); + assertEquals("New Description", updatedFund.getDescription()); + } + + private void verifyBudgetUpdates(FyFinanceDataCollection collection) { + ArgumentCaptor> budgetIdsCaptor = ArgumentCaptor.forClass(List.class); + verify(budgetService).getBudgetsByIds(budgetIdsCaptor.capture(), eq(dbConn)); + assertEquals(collection.getFyFinanceData().get(0).getBudgetId(), budgetIdsCaptor.getValue().get(0)); + + ArgumentCaptor> budgetCaptor = ArgumentCaptor.forClass(List.class); + verify(budgetService).updateBatchBudgets(budgetCaptor.capture(), eq(dbConn)); + Budget updatedBudget = budgetCaptor.getValue().get(0); + + assertNotEquals("NAME CHANGED", updatedBudget.getName()); + assertNotEquals(1000.0, updatedBudget.getInitialAllocation()); + + assertEquals(FyFinanceData.BudgetStatus.INACTIVE.value(), updatedBudget.getBudgetStatus().value()); + assertEquals(800.0, updatedBudget.getAllowableExpenditure()); + assertEquals(700.0, updatedBudget.getAllowableEncumbrance()); + } + + + private FyFinanceDataCollection createTestFinanceDataCollection() { + String fundId = UUID.randomUUID().toString(); + String budgetId = UUID.randomUUID().toString(); + + FyFinanceData financeData = new FyFinanceData() + .withFundId(fundId) + .withBudgetId(budgetId) + .withFundCode("CODE CHANGED") + .withFundName("NAME CHANGED") + .withFundStatus(FyFinanceData.FundStatus.INACTIVE) + .withFundDescription("New Description") + .withFundAcqUnitIds(List.of("unit1")) + .withFundTags(new FundTags().withTagList(List.of("Education"))) + .withBudgetName("NAME CHANGED") + .withBudgetStatus(FyFinanceData.BudgetStatus.INACTIVE) + .withBudgetInitialAllocation(1000.0) + .withBudgetCurrentAllocation(900.0) + .withBudgetAllowableExpenditure(800.0) + .withBudgetAllowableEncumbrance(700.0) + .withBudgetAcqUnitIds(List.of("unit1")); + + return new FyFinanceDataCollection() + .withFyFinanceData(List.of(financeData)); + } +}