Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 17 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "disabled"
}
Saba-Zedginidze-EPAM marked this conversation as resolved.
Show resolved Hide resolved
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));
}
}
170 changes: 168 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,205 @@
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.math.BigDecimal;
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;
}

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);
}

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 allocation = calculateAllocation(financeData);
log.info("createAllocationTransaction:: Creating allocation transaction for fund '{}' and budget '{}' with allocation '{}'",
financeData.getFundId(), financeData.getBudgetId(), allocation);

return new Transaction()
.withTransactionType(Transaction.TransactionType.ALLOCATION)
.withId(UUID.randomUUID().toString())
.withAmount(allocation)
.withFiscalYearId(financeData.getFiscalYearId())
.withToFundId(financeData.getFundId())
.withSource(Transaction.Source.USER)
.withCurrency(currency);
}

private Double calculateAllocation(FyFinanceData financeData) {
var initialAllocation = BigDecimal.valueOf(financeData.getBudgetInitialAllocation());
var allocationChange = BigDecimal.valueOf(financeData.getBudgetAllocationChange());
return initialAllocation.add(allocationChange).doubleValue();
}

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);
SerhiiNosko marked this conversation as resolved.
Show resolved Hide resolved
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
Loading