/** * This file is a part of GrantMaster. * Copyright (C) 2015 Gábor Fehér <feherga@gmail.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.github.gaborfeher.grantmaster.logic.wrappers; import com.github.gaborfeher.grantmaster.framework.base.EntityWrapper; import com.github.gaborfeher.grantmaster.framework.utils.DatabaseSingleton; import com.github.gaborfeher.grantmaster.logic.entities.ExpenseSourceAllocation; import com.github.gaborfeher.grantmaster.logic.entities.BudgetCategory; import com.github.gaborfeher.grantmaster.logic.entities.Project; import com.github.gaborfeher.grantmaster.logic.entities.ProjectExpense; import com.github.gaborfeher.grantmaster.framework.utils.Utils; import com.github.gaborfeher.grantmaster.logic.entities.ProjectReport; import com.github.gaborfeher.grantmaster.logic.entities.ProjectSource; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import javax.validation.constraints.AssertTrue; import javax.validation.constraints.DecimalMin; import javax.validation.constraints.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ProjectExpenseWrapper extends EntityWrapper<ProjectExpense> { private static final Logger logger = LoggerFactory.getLogger(ProjectExpenseWrapper.class); private static final String EXPENSE_LIST_DATE_FILTER_QUERY_CONDITION = "(:reportDate IS NULL OR e.report.reportDate > :reportDate OR (e.report.reportDate = :reportDate AND (:paymentDate IS NULL OR e.paymentDate >= :paymentDate)))"; @NotNull(message="%ValidationErrorExpenseAmount") @DecimalMin(value="0.01", message="%ValidationErrorExpenseAmount") private BigDecimal accountingCurrencyAmount; private final BigDecimal accountingCurrencyAmountNotEdited; private BigDecimal grantCurrencyAmount; private BigDecimal exchangeRate; public ProjectExpenseWrapper(ProjectExpense expense) { super(expense); this.exchangeRate = BigDecimal.ZERO; this.accountingCurrencyAmount = BigDecimal.ZERO; this.grantCurrencyAmount = BigDecimal.ZERO; if (expense.getExchangeRateOverride() != null) { this.exchangeRate = expense.getExchangeRateOverride(); this.accountingCurrencyAmount = expense.getAccountingCurrencyAmountOverride(); this.grantCurrencyAmount = expense.getAccountingCurrencyAmountOverride().divide(exchangeRate, Utils.MC); } else if (expense.getSourceAllocations() != null) { this.accountingCurrencyAmount = BigDecimal.ZERO; this.grantCurrencyAmount = BigDecimal.ZERO; for (ExpenseSourceAllocation allocation : expense.getSourceAllocations()) { this.accountingCurrencyAmount = this.accountingCurrencyAmount.add( allocation.getAccountingCurrencyAmount(), Utils.MC); this.grantCurrencyAmount = this.grantCurrencyAmount.add( allocation.getAccountingCurrencyAmount().divide(allocation.getSource().getExchangeRate(), Utils.MC), Utils.MC); } this.exchangeRate = grantCurrencyAmount.compareTo(BigDecimal.ZERO) <= 0 ? null : accountingCurrencyAmount.divide(grantCurrencyAmount, Utils.MC); } this.accountingCurrencyAmountNotEdited = accountingCurrencyAmount; } @AssertTrue(message="%ValidationErrorExpenseConsistency") private boolean isExpenseConsistencyHeld() { ProjectExpense expense = getEntity(); if (expense.getProject() == null || expense.getProject().getAccountCurrency() == null || expense.getOriginalCurrency() == null || expense.getOriginalAmount() == null || accountingCurrencyAmount == null) { return false; } Project project = expense.getProject(); if (!expense.getOriginalCurrency().equals(project.getAccountCurrency())) { // Nothing to check if they are not equal. return true; } return 0 == expense.getOriginalAmount().compareTo(accountingCurrencyAmount); } @AssertTrue(message="%ValidationErrorExpenseExchangeRateMissing") private boolean isExchangeRateSpecified() { ProjectExpense expense = getEntity(); if (expense.getProject().getExpenseMode() != Project.ExpenseMode.OVERRIDE_AUTO_BY_RATE_TABLE) { return true; // Nothing to check in this case. } return expense.getExchangeRateOverride() != null; } @AssertTrue(message="%ValidationErrorLockedReport") private boolean isEditingAllowed() { return ProjectReport.Status.OPEN.equals(getEntity().getReport().getStatus()); } @Override protected boolean checkIsLocked() { if (getEntity().getReport().getStatus() != ProjectReport.Status.CLOSED) { return false; } validate(); // trigger error message return true; } /** * Adds an expense source allocation to this expense. In other words, * spent money is added. Note that grantCurrencyAmount is updated but * accountingCurrencyAmount is not, because it is assumed that * recalculateAllocations is taking care of it. */ private void addAllocation( ProjectSource source, BigDecimal accountingCurrencyAmountToTake) { grantCurrencyAmount = grantCurrencyAmount.add( accountingCurrencyAmountToTake.divide(source.getExchangeRate(), Utils.MC), Utils.MC); ExpenseSourceAllocation allocation = new ExpenseSourceAllocation(); allocation.setExpense(entity); allocation.setSource(source); allocation.setAccountingCurrencyAmount(accountingCurrencyAmountToTake); entity.getSourceAllocations().add(allocation); } /** * Recalculate the sourceAllocations array. So that this expense uses the * earliest free sources for its cost. Preconditions: the * accountingCurrencyAmount member variable should store the desired value * of this expense. sourceAllocations should be empty, and any previously * used allocations should be deleted (and flushed) from the database. */ private void recalculateAllocations(EntityManager em, List<ProjectSourceWrapper> sources) { BigDecimal remainingAccountingCurrencyAmount = accountingCurrencyAmount; grantCurrencyAmount = BigDecimal.ZERO; entity.setSourceAllocations(new ArrayList<>()); for (int i = 0; i < sources.size(); ++i) { ProjectSource source = sources.get(i).getEntity(); if (remainingAccountingCurrencyAmount.compareTo(BigDecimal.ZERO) <= 0) { break; // Done } // Minimum of needed amount and available amount from this source: BigDecimal take = remainingAccountingCurrencyAmount.min(source.getRemainingAccountingCurrencyAmount()); if (i == sources.size() - 1) { // Allow of going below zero balance for the last source. take = remainingAccountingCurrencyAmount; } if (take.compareTo(BigDecimal.ZERO) > 0) { remainingAccountingCurrencyAmount = remainingAccountingCurrencyAmount.subtract(take, Utils.MC); addAllocation(source, take); // Update the transient member remainingAccountingCurrencyAmount of // source so that the subsequent invocations of this method can reuse // the sources list. (Instead of re-querying it.) source.setRemainingAccountingCurrencyAmount( source.getRemainingAccountingCurrencyAmount().subtract(take)); } } exchangeRate = accountingCurrencyAmount.divide(grantCurrencyAmount, Utils.MC); for (ExpenseSourceAllocation allocation : entity.getSourceAllocations()) { em.persist(allocation); } } private static void removeExpenseAllocations( EntityManager em, Project project, LocalDate startingFromReportDate, LocalDate startingFromExpenseDate) { em.createQuery("DELETE FROM ExpenseSourceAllocation a WHERE a.expense IN (" + "SELECT e FROM ProjectExpense e WHERE e.project = :project AND " + EXPENSE_LIST_DATE_FILTER_QUERY_CONDITION + ")") .setParameter("project", project) .setParameter("reportDate", startingFromReportDate) .setParameter("paymentDate", startingFromExpenseDate) .executeUpdate(); } public static void updateExpenseAllocations( EntityManager em, Project project, LocalDate startingFromReportDate, LocalDate startingFromExpenseDate) { List<ProjectExpenseWrapper> expensesToUpdate = getProjectExpenseListForAllocation(em, project, startingFromReportDate, startingFromExpenseDate); // Delete allocations and flush this to database. (Accounting currency amounts // are still kept in the expensesToUpdate array.) removeExpenseAllocations( em, project, startingFromReportDate, startingFromExpenseDate); em.flush(); // Recompute expenses. List<ProjectSourceWrapper> sources = ProjectSourceWrapper.getProjectSourceListForAllocation(em, project); for (ProjectExpenseWrapper e : expensesToUpdate) { e.recalculateAllocations(em, sources); } } private boolean makeSimpleFakeExpenseSourceAllocations( EntityManager em, BigDecimal editedAccountingCurrencyAmount) { // Exchange rate is computed here. (Classic way of expenses.) // Set the allocated amount to be right for this entity and flush to database. // This is just an initial fake setting which will be removed while normalizing. ExpenseSourceAllocation allocation; if (entity.getSourceAllocations().size() > 0) { allocation = entity.getSourceAllocations().get(0); allocation.setAccountingCurrencyAmount(editedAccountingCurrencyAmount); while (entity.getSourceAllocations().size() > 1) { entity.getSourceAllocations().remove(1); } } else { ProjectSource source0 = ProjectSourceWrapper.getOneProjectSource(em, entity.getProject()); if (source0 == null) { return false; } allocation = new ExpenseSourceAllocation(); em.persist(allocation); allocation.setExpense(entity); allocation.setAccountingCurrencyAmount(editedAccountingCurrencyAmount); allocation.setSource(source0); entity.getSourceAllocations().add(allocation); } em.flush(); return true; } private boolean propagateAccountingCurrencyAmountChange( EntityManager em, BigDecimal editedAccountingCurrencyAmount) { switch (entity.getProject().getExpenseMode()) { case NORMAL_AUTO_BY_SOURCE: if (makeSimpleFakeExpenseSourceAllocations(em, editedAccountingCurrencyAmount)) { updateExpenseAllocations(em, entity.getProject(), entity.getReport().getReportDate(), entity.getPaymentDate()); return true; } else { return false; } case OVERRIDE_AUTO_BY_RATE_TABLE: entity.setAccountingCurrencyAmountOverride(editedAccountingCurrencyAmount); grantCurrencyAmount = editedAccountingCurrencyAmount.divide(exchangeRate, Utils.MC); return true; default: logger.error("unknown expenseMode"); return false; } } protected boolean checkConsistencyBeforeSave() { switch (entity.getProject().getExpenseMode()) { case OVERRIDE_AUTO_BY_RATE_TABLE: if (entity.getExchangeRateOverride() == null || entity.getAccountingCurrencyAmountOverride() == null) { logger.error("saving expense failed with missing accounting currency amount or exchange rate override"); return false; } return true; case NORMAL_AUTO_BY_SOURCE: if (entity.getSourceAllocations() == null || entity.getSourceAllocations().isEmpty()) { logger.error("saving expense failed with empty alloc list"); return false; } return true; default: logger.error("unknown expenseMode"); return false; } } @Override protected boolean saveInternal(EntityManager em) { if (ProjectReport.Status.CLOSED.equals(entity.getReport().getStatus())) { return false; } if (!super.saveInternal(em)) { return false; } BigDecimal editedAccountingCurrencyAmount = null; if (getAccountingCurrencyAmount().compareTo(accountingCurrencyAmountNotEdited) != 0) { editedAccountingCurrencyAmount = getAccountingCurrencyAmount(); } if (editedAccountingCurrencyAmount != null) { if (!propagateAccountingCurrencyAmountChange(em, editedAccountingCurrencyAmount)) { return false; } } return checkConsistencyBeforeSave(); } @Override public Object getProperty(String name) { if ("accountingCurrencyAmount".equals(name)) { return getAccountingCurrencyAmount(); } else if ("grantCurrencyAmount".equals(name)) { return getGrantCurrencyAmount(); } else if ("exchangeRate".equals(name)) { return getExchangeRate(); } return super.getProperty(name); } @Override public boolean setProperty(String name, Object value, Class<?> paramType) { // If the below condition holds, then the two amount values are tied // together: updating one should update the other. boolean amountsAreInterlocked = entity.getOriginalCurrency() != null && entity.getOriginalCurrency().equals(entity.getProject().getAccountCurrency()); if ("accountingCurrencyAmount".equals(name)) { BigDecimal backup = accountingCurrencyAmount; accountingCurrencyAmount = (BigDecimal) value; if (amountsAreInterlocked) { if (!super.setProperty("originalAmount", value, paramType)) { accountingCurrencyAmount = backup; return false; } } return true; } else if ("originalAmount".equals(name)) { if (super.setProperty(name, value, paramType)) { if (amountsAreInterlocked) { accountingCurrencyAmount = (BigDecimal) value; } return true; } else { return false; } } else if ("exchangeRate".equals(name)) { // Manual setting of exchange rate is only allowed in "override" mode // projects. if (getEntity().getProject().getExpenseMode() == Project.ExpenseMode.OVERRIDE_AUTO_BY_RATE_TABLE) { setExchangeRate((BigDecimal) value); return true; } else { return false; } } else if ("paymentDate".equals(name)) { return setPaymentDate((LocalDate) value); } else if ("project".equals(name)) { return setProject((Project) value); } else { return super.setProperty(name, value, paramType); } } private boolean setProject(Project newProject) { if (entity.getProject().getExpenseMode() != Project.ExpenseMode.NORMAL_AUTO_BY_SOURCE || newProject.getExpenseMode() != Project.ExpenseMode.NORMAL_AUTO_BY_SOURCE) { Utils.showSimpleErrorDialog( "Expense.setProject.ErrorTitle", "Expense.setProject.BadProjectMode.Description", new ArrayList()); return false; } return DatabaseSingleton.INSTANCE.transaction((EntityManager em) -> { if (ProjectSourceWrapper.countProjectSources(em, newProject) == 0) { Utils.showSimpleErrorDialog( "Expense.setProject.ErrorTitle", "Expense.setProject.MissingSource.Description", new ArrayList()); return false; } ProjectReport newReport = ProjectReportWrapper.getDefaultProjectReport(em, newProject); Project oldProject = entity.getProject(); ProjectReport oldReport = entity.getReport(); entity.setProject(newProject); entity.setReport(newReport); entity.setSourceAllocations(new ArrayList<>()); entity = em.merge(entity); em.flush(); if (checkIsLocked()) { return false; } updateExpenseAllocations( em, oldProject, oldReport.getReportDate(), null); em.flush(); propagateAccountingCurrencyAmountChange(em, accountingCurrencyAmount); return true; }); } private boolean setPaymentDate(LocalDate paymentDate) { if (!super.setProperty("paymentDate", paymentDate, LocalDate.class)) { return false; } if (getEntity().getProject().getExpenseMode() == Project.ExpenseMode.OVERRIDE_AUTO_BY_RATE_TABLE) { DatabaseSingleton.INSTANCE.query((EntityManager em) -> { setExchangeRate( ExchangeRateItemWrapper.getExchangeRate( em, getEntity().getProject(), (LocalDate) paymentDate)); return true; }); } return true; } public static ProjectExpenseWrapper createNew(EntityManager em, Project project) { ProjectExpense expense = new ProjectExpense(); expense.setProject(project); expense.setOriginalCurrency(project.getAccountCurrency()); expense.setReport(ProjectReportWrapper.getDefaultProjectReport(em, project)); ProjectExpenseWrapper wrapper = new ProjectExpenseWrapper(expense); return wrapper; } @Override public boolean delete(EntityManager em) { if (ProjectReport.Status.CLOSED.equals(entity.getReport().getStatus())) { return false; } ProjectExpense mergedExpense = em.find(ProjectExpense.class, entity.getId()); em.remove(mergedExpense); em.flush(); updateExpenseAllocations( em, mergedExpense.getProject(), entity.getReport().getReportDate(), null); requestTableRefresh(); return true; } public static List<ProjectExpenseWrapper> getProjectExpenseList(EntityManager em, Project project) { return getProjectExpenseListQuery(em, project, true, "").getResultList(); } /** * @return List of expenses in the order they should be taken when allocating * sources.( Note that filtering is only based on report date, therefore * earlier expenses in a report may be have their allocations recalculated * unnecessarily.) */ static List<ProjectExpenseWrapper> getProjectExpenseListForAllocation( EntityManager em, Project project, LocalDate earliestReportDate, LocalDate earliestPaymentDate) { TypedQuery<ProjectExpenseWrapper> query = getProjectExpenseListQuery( em, project, false, " AND " + EXPENSE_LIST_DATE_FILTER_QUERY_CONDITION); query.setParameter("reportDate", earliestReportDate); query.setParameter("paymentDate", earliestPaymentDate); return query.getResultList(); } private static TypedQuery<ProjectExpenseWrapper> getProjectExpenseListQuery(EntityManager em, Project project, boolean descending, String extraWhere) { String sortString = descending ? " DESC" : ""; String queryString = "SELECT new com.github.gaborfeher.grantmaster.logic.wrappers.ProjectExpenseWrapper(e) " + "FROM ProjectExpense e LEFT OUTER JOIN ProjectReport r ON e.report = r " + "WHERE e.project = :project " + extraWhere + " " + "GROUP BY e, r " + "ORDER BY r.reportDate " + sortString + ", e.paymentDate " + sortString + ", e.id " + sortString; return em.createQuery(queryString, ProjectExpenseWrapper.class). setParameter("project", project); } public static List<ProjectExpenseWrapper> getExpenseList( EntityManager em, Project project, LocalDate startDate, LocalDate endDate, BudgetCategory budgetCategory, String budgetCategoryGroup, String accountNo, String partnerName, String comment1, String comment2) { if ("".equals(budgetCategoryGroup)) { budgetCategoryGroup = null; } if ("".equals(accountNo)) { accountNo = null; } if ("".equals(partnerName)) { partnerName = null; } if ("".equals(comment1)) { comment1 = null; } if ("".equals(comment2)) { comment2 = null; } TypedQuery<ProjectExpenseWrapper> query = em.createQuery( "SELECT new com.github.gaborfeher.grantmaster.logic.wrappers.ProjectExpenseWrapper(e) " + "FROM ProjectExpense e " + "WHERE (e.project.id = :project OR :project IS NULL) " + " AND (e.paymentDate >= :startDate OR :startDate IS NULL) " + " AND (e.paymentDate <= :endDate OR :endDate IS NULL) " + " AND (e.budgetCategory.id = :budgetCategory OR :budgetCategory IS NULL) " + " AND (e.budgetCategory.groupName = :budgetCategoryGroup OR :budgetCategoryGroup IS NULL) " + " AND (LOCATE(LOWER(:accountNo), LOWER(e.accountNo)) > 0 OR :accountNo IS NULL) " + " AND (LOCATE(LOWER(:partnerName), LOWER(e.partnerName)) > 0 OR :partnerName IS NULL) " + " AND (LOCATE(LOWER(:comment1), LOWER(e.comment1)) > 0 OR :comment1 IS NULL) " + " AND (LOCATE(LOWER(:comment2), LOWER(e.comment2)) > 0 OR :comment2 IS NULL) " + "GROUP BY e " + "ORDER BY e.paymentDate, e.id", ProjectExpenseWrapper.class); query.setParameter("project", project == null ? null : project.getId()); query.setParameter("startDate", startDate); query.setParameter("endDate", endDate); query.setParameter("budgetCategory", budgetCategory == null ? null : budgetCategory.getId()); query.setParameter("budgetCategoryGroup", budgetCategoryGroup); query.setParameter("partnerName", partnerName); query.setParameter("accountNo", accountNo); query.setParameter("comment1", comment1); query.setParameter("comment2", comment2); return query.getResultList(); } public static void removeProjectExpenses(EntityManager em, Project project) { em.createQuery("DELETE FROM ExpenseSourceAllocation a WHERE a IN (SELECT a FROM ExpenseSourceAllocation a, ProjectExpense e WHERE a.expense = e AND e.project = :project)"). setParameter("project", project). executeUpdate(); em.createQuery("DELETE FROM ProjectExpense p WHERE p.project = :project"). setParameter("project", project). executeUpdate(); } public BigDecimal getAccountingCurrencyAmount() { return accountingCurrencyAmount; } public BigDecimal getGrantCurrencyAmount() { return grantCurrencyAmount; } public BigDecimal getExchangeRate() { return exchangeRate; } public void setExchangeRate(BigDecimal exchangeRate) { this.exchangeRate = exchangeRate; entity.setExchangeRateOverride(exchangeRate); if (entity.getSourceAllocations() != null) { entity.getSourceAllocations().clear(); } } @Override protected boolean fillRandom(EntityManager em) { entity.setPaymentDate(LocalDate.of(2015, 12, Utils.testRandom.nextInt(31) + 1)); setProperty("accountingCurrencyAmount", Utils.testRandom.nextBig(100, 100000), BigDecimal.class); entity.setBudgetCategory(Utils.testRandom.pickFromList(GlobalBudgetCategoryWrapper.getBudgetCategories(em, BudgetCategory.Direction.PAYMENT))); return true; } }