Skip to content

Commit

Permalink
[MODFIN-381] - Implement endpoint to process FY finance bulk update (#…
Browse files Browse the repository at this point in the history
…264)

* [MODFIN-381] - Implement endpoint to process FY finance bulk update

* [MODFIN-379] - Create API for fund updates logs

* [MODFIN-381] - Implement endpoint to process FY finance bulk update

* [MODFIN-381] - Added validation

* [MODFIN-381] - Implement endpoint to process FY finance bulk update

* fixed error handling

* [MODFIN-381] - Implement endpoint to process FY finance bulk update

* [MODFIN-381] - Implement endpoint to process FY finance bulk update

* [MODFIN-381] - Implement endpoint to process FY finance bulk update

* Fixed unit tests

* Fixed unit tests and improved validation

* Fixed unit tests

* Optimize transaction creation, Fix unit tests

* change acq-model to master and apply recommendations

* revert back changes

* Update create allocation transaction

* Fixed unit test
  • Loading branch information
azizbekxm authored Dec 6, 2024
1 parent f38fea2 commit 44bebfc
Show file tree
Hide file tree
Showing 11 changed files with 571 additions and 15 deletions.
24 changes: 23 additions & 1 deletion descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,14 @@
"acquisitions-units-storage.units.collection.get",
"acquisitions-units-storage.memberships.collection.get"
]
},
{
"methods": ["PUT"],
"pathPattern": "/finance/finance-data",
"permissionsRequired": ["finance.finance-data.collection.put"],
"modulePermissions": [
"finance-storage.finance-data.collection.put"
]
}
]
},
Expand Down Expand Up @@ -1378,6 +1386,20 @@
"displayName": "Finances - get finance data collection",
"description": "Get finance data collection"
},
{
"permissionName": "finance.finance-data.collection.put",
"displayName": "Finances - update finance data collection",
"description": "Update finance data collection"
},
{
"permissionName": "finance.finance-data.all",
"displayName": "Finance data - all permissions",
"description": "All finance data permissions",
"subPermissions": [
"finance.finance-data.collection.get",
"finance.finance-data.collection.put"
]
},
{
"permissionName": "finance.all",
"displayName": "Finance module - all permissions",
Expand All @@ -1398,7 +1420,7 @@
"finance.expense-classes.all",
"finance.acquisitions-units-assignments.all",
"finance.fund-update-logs.all",
"finance.finance-data.collection.get"
"finance.finance-data.all"
],
"visible": false
},
Expand Down
35 changes: 34 additions & 1 deletion ramls/finance-data.raml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,45 @@ resourceTypes:
/finance/finance-data:
type:
collection-get:
exampleCollection: !include acq-models/mod-finance/examples/fy_finance_data_collection.sample
exampleCollection: !include acq-models/mod-finance/examples/fy_finance_data_collection_get.sample
schemaCollection: fy-finance-data-collection
get:
description: Get finance data
is: [
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_put.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"


6 changes: 4 additions & 2 deletions src/main/java/org/folio/config/ServicesConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,9 @@ FundUpdateLogService fundUpdateLogService(RestClient restClient) {
}

@Bean
FinanceDataService financeDataService(RestClient restClient, AcqUnitsService acqUnitsService) {
return new FinanceDataService(restClient, acqUnitsService);
FinanceDataService financeDataService(RestClient restClient, AcqUnitsService acqUnitsService,
TransactionApiService transactionApiService, FiscalYearService fiscalYearService,
FundUpdateLogService fundUpdateLogService) {
return new FinanceDataService(restClient, acqUnitsService, transactionApiService, fiscalYearService, fundUpdateLogService);
}
}
9 changes: 9 additions & 0 deletions src/main/java/org/folio/rest/impl/FinanceDataApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import javax.ws.rs.core.Response;
import org.folio.rest.annotations.Validate;
import org.folio.rest.core.models.RequestContext;
import org.folio.rest.jaxrs.model.FyFinanceDataCollection;
import org.folio.rest.jaxrs.resource.FinanceFinanceData;
import org.folio.services.financedata.FinanceDataService;
import org.folio.spring.SpringContextUtil;
Expand All @@ -34,4 +35,12 @@ public void getFinanceFinanceData(String query, String totalRecords, int offset,
.onSuccess(financeData -> asyncResultHandler.handle(succeededFuture(buildOkResponse(financeData))))
.onFailure(fail -> handleErrorResponse(asyncResultHandler, fail));
}

@Override
@Validate
public void putFinanceFinanceData(FyFinanceDataCollection entity, Map<String, String> okapiHeaders, Handler<AsyncResult<Response>> asyncResultHandler, Context vertxContext) {
financeDataService.putFinanceData(entity, new RequestContext(vertxContext, okapiHeaders))
.onSuccess(v -> asyncResultHandler.handle(succeededFuture(buildNoContentResponse())))
.onFailure(fail -> handleErrorResponse(asyncResultHandler, fail));
}
}
194 changes: 192 additions & 2 deletions src/main/java/org/folio/services/financedata/FinanceDataService.java
Original file line number Diff line number Diff line change
@@ -1,39 +1,229 @@
package org.folio.services.financedata;

import static io.vertx.core.Future.succeededFuture;
import static org.folio.rest.jaxrs.model.FundUpdateLog.Status.COMPLETED;
import static org.folio.rest.jaxrs.model.FundUpdateLog.Status.ERROR;
import static org.folio.rest.util.HelperUtils.combineCqlExpressions;
import static org.folio.rest.util.ResourcePathResolver.FINANCE_DATA_STORAGE;
import static org.folio.rest.util.ResourcePathResolver.resourcesPath;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import io.vertx.core.Future;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.folio.rest.core.RestClient;
import org.folio.rest.core.models.RequestContext;
import org.folio.rest.core.models.RequestEntry;
import org.folio.rest.exception.HttpException;
import org.folio.rest.jaxrs.model.Batch;
import org.folio.rest.jaxrs.model.Error;
import org.folio.rest.jaxrs.model.Errors;
import org.folio.rest.jaxrs.model.FundUpdateLog;
import org.folio.rest.jaxrs.model.FyFinanceData;
import org.folio.rest.jaxrs.model.FyFinanceDataCollection;
import org.folio.rest.jaxrs.model.JobDetails;
import org.folio.rest.jaxrs.model.Parameter;
import org.folio.rest.jaxrs.model.Transaction;
import org.folio.services.fiscalyear.FiscalYearService;
import org.folio.services.fund.FundUpdateLogService;
import org.folio.services.protection.AcqUnitsService;
import org.folio.services.transactions.TransactionApiService;

@Log4j2
public class FinanceDataService {

private final RestClient restClient;
private final AcqUnitsService acqUnitsService;
private final TransactionApiService transactionApiService;
private final FiscalYearService fiscalYearService;
private final FundUpdateLogService fundUpdateLogService;

public FinanceDataService(RestClient restClient, AcqUnitsService acqUnitsService) {
public FinanceDataService(RestClient restClient, AcqUnitsService acqUnitsService, TransactionApiService transactionApiService,
FiscalYearService fiscalYearService, FundUpdateLogService fundUpdateLogService) {
this.restClient = restClient;
this.acqUnitsService = acqUnitsService;
this.transactionApiService = transactionApiService;
this.fiscalYearService = fiscalYearService;
this.fundUpdateLogService = fundUpdateLogService;
}

/**
* The method will fetch finance data with acq units restriction
* 1. First fetch acq units for the user
* 2. Build cql clause for finance data with acq units
*
* @param query query to filter finance data
* @param offset offset
* @param limit limit
* @param requestContext request context
* @return future with finance data collection
*/
public Future<FyFinanceDataCollection> getFinanceDataWithAcqUnitsRestriction(String query, int offset, int limit,
RequestContext requestContext) {
log.debug("Trying to get finance data with acq units restriction, query={}", query);
return acqUnitsService.buildAcqUnitsCqlClauseForFinanceData(requestContext)
.map(clause -> StringUtils.isEmpty(query) ? clause : combineCqlExpressions("and", clause, query))
.compose(effectiveQuery -> getFinanceData(effectiveQuery, offset, limit, requestContext));
}

private Future<FyFinanceDataCollection> getFinanceData(String query, int offset, int limit, RequestContext requestContext) {
private Future<FyFinanceDataCollection> getFinanceData(String query, int offset, int limit,
RequestContext requestContext) {
var requestEntry = new RequestEntry(resourcesPath(FINANCE_DATA_STORAGE))
.withOffset(offset)
.withLimit(limit)
.withQuery(query);
return restClient.get(requestEntry.buildEndpoint(), FyFinanceDataCollection.class, requestContext);
}

/**
* The method will update finance data collection in one operation.
* 1. Validate finance data collection, if it fails validation exception will be thrown
* 2. Create allocation transactions, if it fails process will stop
* 3. Update finance data by invoking storage API
* 4. Save logs of the operation with COMPLETE or ERROR status
*
* @param financeDataCollection finance data collection to update
* @param requestContext request context
* @return future with void result
*/
public Future<Void> putFinanceData(FyFinanceDataCollection financeDataCollection, RequestContext requestContext) {
log.debug("Trying to update finance data collection with size: {}", financeDataCollection.getTotalRecords());
if (CollectionUtils.isEmpty(financeDataCollection.getFyFinanceData())) {
log.info("putFinanceData:: Finance data collection is empty, nothing to update");
return succeededFuture();
}

validateFinanceDataCollection(financeDataCollection, getFiscalYearId(financeDataCollection));

return processAllocationTransaction(financeDataCollection, requestContext)
.compose(v -> updateFinanceData(financeDataCollection, requestContext))
.onSuccess(asyncResult -> processLogs(financeDataCollection, requestContext, COMPLETED))
.onFailure(asyncResult -> processLogs(financeDataCollection, requestContext, ERROR));
}

private void validateFinanceDataCollection(FyFinanceDataCollection financeDataCollection, String fiscalYearId) {
for (int i = 0; i < financeDataCollection.getFyFinanceData().size(); i++) {
var financeData = financeDataCollection.getFyFinanceData().get(i);
validateFinanceDataFields(financeData, i, fiscalYearId);

var allocationChange = financeData.getBudgetAllocationChange();
var initialAllocation = financeData.getBudgetInitialAllocation();

if (allocationChange < 0 && Math.abs(allocationChange) > initialAllocation) {
var error = createError("Allocation change cannot be greater than initial allocation",
String.format("financeData[%s].budgetAllocationChange", i), String.valueOf(financeData.getBudgetAllocationChange()));
throw new HttpException(422, new Errors().withErrors(List.of(error)));
}
}
}

private Future<Void> processAllocationTransaction(FyFinanceDataCollection fyFinanceDataCollection,
RequestContext requestContext) {
return fiscalYearService.getFiscalYearById(getFiscalYearId(fyFinanceDataCollection), requestContext)
.map(fiscalYear -> createAllocationTransactions(fyFinanceDataCollection, fiscalYear.getCurrency()))
.compose(transactions -> createBatchTransaction(transactions, requestContext));
}

private String getFiscalYearId(FyFinanceDataCollection fyFinanceDataCollection) {
return fyFinanceDataCollection.getFyFinanceData().get(0).getFiscalYearId();
}

private List<Transaction> createAllocationTransactions(FyFinanceDataCollection financeDataCollection, String currency) {
return financeDataCollection.getFyFinanceData().stream()
.map(financeData -> createAllocationTransaction(financeData, currency))
.toList();
}

private Transaction createAllocationTransaction(FyFinanceData financeData, String currency) {
var allocationChange = financeData.getBudgetAllocationChange();
log.info("createAllocationTransaction:: Creating allocation transaction for fund '{}' and budget '{}' with allocation '{}'",
financeData.getFundId(), financeData.getBudgetId(), allocationChange);

var transaction = new Transaction()
.withTransactionType(Transaction.TransactionType.ALLOCATION)
.withId(UUID.randomUUID().toString())
.withAmount(Math.abs(allocationChange))
.withFiscalYearId(financeData.getFiscalYearId())
.withSource(Transaction.Source.USER)
.withCurrency(currency);

// For negative allocation (decrease), use fromFundId
// For positive allocation (increase), use toFundId
if (allocationChange > 0) {
transaction.withToFundId(financeData.getFundId());
} else {
transaction.withFromFundId(financeData.getFundId());
}

return transaction;
}

public Future<Void> createBatchTransaction(List<Transaction> transactions, RequestContext requestContext) {
Batch batch = new Batch().withTransactionsToCreate(transactions);
return transactionApiService.processBatch(batch, requestContext);
}

private Future<Void> updateFinanceData(FyFinanceDataCollection financeDataCollection,
RequestContext requestContext) {
log.debug("updateFinanceData:: Trying to update finance data collection with size: {}", financeDataCollection.getTotalRecords());
return restClient.put(resourcesPath(FINANCE_DATA_STORAGE), financeDataCollection, requestContext);
}

private void processLogs(FyFinanceDataCollection financeDataCollection,
RequestContext requestContext, FundUpdateLog.Status status) {
var jobDetails = new JobDetails().withAdditionalProperty("fyFinanceData", financeDataCollection.getFyFinanceData());
var fundUpdateLog = new FundUpdateLog().withId(UUID.randomUUID().toString())
.withJobName("Update finance data") // TODO: Update job name generation
.withStatus(status)
.withRecordsCount(financeDataCollection.getTotalRecords())
.withJobDetails(jobDetails)
.withJobNumber(1);
fundUpdateLogService.createFundUpdateLog(fundUpdateLog, requestContext);
}

private void validateFinanceDataFields(FyFinanceData financeData, int i, String fiscalYearId) {
var errors = new Errors().withErrors(new ArrayList<>());

if (!financeData.getFiscalYearId().equals(fiscalYearId)) {
errors.getErrors().add(createError(
String.format("Fiscal year ID must be the same as other fiscal year ID(s) '[%s]' in the request", fiscalYearId),
String.format("financeData[%s].fiscalYearId", i), financeData.getFiscalYearId())
);
}

validateField(errors, String.format("financeData[%s].fundCode", i), financeData.getFundCode(), "Fund code is required");
validateField(errors, String.format("financeData[%s].fundName", i), financeData.getFundName(), "Fund name is required");
validateField(errors, String.format("financeData[%s].fundDescription", i), financeData.getFundDescription(), "Fund description is required");
validateField(errors, String.format("financeData[%s].fundStatus", i), financeData.getFundStatus(), "Fund status is required");
validateField(errors, String.format("financeData[%s].budgetId", i), financeData.getBudgetId(), "Budget ID is required");
validateField(errors, String.format("financeData[%s].budgetName", i), financeData.getBudgetName(), "Budget name is required");
validateField(errors, String.format("financeData[%s].budgetStatus", i), financeData.getBudgetStatus(), "Budget status is required");
validateField(errors, String.format("financeData[%s].budgetInitialAllocation", i), financeData.getBudgetInitialAllocation(), "Budget initial allocation is required");
validateField(errors, String.format("financeData[%s].budgetAllocationChange", i), financeData.getBudgetAllocationChange(), "Allocation change is required");
validateField(errors, String.format("financeData[%s].budgetAllowableExpenditure", i), financeData.getBudgetAllowableExpenditure(), "Budget allowable expenditure is required");
validateField(errors, String.format("financeData[%s].budgetAllowableEncumbrance", i), financeData.getBudgetAllowableEncumbrance(), "Budget allowable encumbrance is required");
validateField(errors, String.format("financeData[%s].transactionDescription", i), financeData.getTransactionDescription(), "Transaction description is required");
validateField(errors, String.format("financeData[%s].transactionTag", i), financeData.getTransactionTag(), "Transaction tag is required");

if (CollectionUtils.isNotEmpty(errors.getErrors())) {
throw new HttpException(422, errors);
}
}

private void validateField(Errors errors, String fieldName, Object fieldValue, String errorMessage) {
if (fieldValue == null) {
errors.getErrors().add(createError(errorMessage, fieldName, "null"));
}
}

private Error createError(String message, String key, String value) {
log.warn("Validation error: {}", message);
var param = new Parameter().withKey(key).withValue(value);
return new Error().withMessage(message).withParameters(List.of(param));
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ public Future<Void> unreleaseEncumbrance(String id, RequestContext requestContex
.onFailure(t -> log.error("Error unreleasing encumbrance, id={}", id, t));
}


private void validateTransactionType(Transaction transaction, TransactionType transactionType) {
if (transaction.getTransactionType() != transactionType) {
log.warn("validateTransactionType:: Transaction '{}' type mismatch. '{}' expected", transaction.getId(), transactionType);
Expand Down
Loading

0 comments on commit 44bebfc

Please sign in to comment.