/* * The Kuali Financial System, a comprehensive financial management system for higher education. * * Copyright 2005-2014 The Kuali Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kuali.kfs.gl.batch.service.impl; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import org.apache.commons.lang.StringUtils; import org.kuali.kfs.gl.batch.dataaccess.ReconciliationDao; import org.kuali.kfs.gl.batch.service.ReconciliationService; import org.kuali.kfs.gl.businessobject.OriginEntryFull; import org.kuali.kfs.gl.exception.LoadException; import org.kuali.kfs.sys.Message; import org.kuali.rice.core.api.util.type.KualiDecimal; import org.kuali.rice.core.api.util.type.TypeUtils; import org.springframework.transaction.annotation.Transactional; /** * Default implementation of ReconciliationService */ @Transactional public class ReconciliationServiceImpl implements ReconciliationService { private static org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(ReconciliationServiceImpl.class); private ReconciliationDao reconciliationDao; private Class<? extends OriginEntryFull> originEntryClass; /** * A wrapper around {@link ColumnReconciliation} objects to provide it with information specific to the java beans representing * each BO. <br/><br/> In the default implementation of {@link org.kuali.kfs.gl.batch.service.ReconciliationParserService}, each * {@link ColumnReconciliation} object may actually represent the sum of multiple fields across all origin entries (i.e. * ColumnReconciliation.getTokenizedFieldNames().length may be > 1). <br/><br/> Furthermore, the parser service returns * database field names as the identifier. This service requires the use of java bean names, so this class is used to maintain a * mapping between the DB names (in columnReconciliation.getTokenizedFieldNames()) and the java bean names (in * javaAttributeNames). These lists/arrays are the same size, and each element at the same position in both lists are mapped to * each other. */ protected class JavaAttributeAugmentedColumnReconciliation { protected ColumnReconciliation columnReconciliation; protected List<String> javaAttributeNames; protected JavaAttributeAugmentedColumnReconciliation() { columnReconciliation = null; javaAttributeNames = null; } /** * Gets the columnReconciliation attribute. * * @return Returns the columnReconciliation. */ protected ColumnReconciliation getColumnReconciliation() { return columnReconciliation; } /** * Sets the columnReconciliation attribute value. * * @param columnReconciliation The columnReconciliation to set. */ protected void setColumnReconciliation(ColumnReconciliation columnReconciliation) { this.columnReconciliation = columnReconciliation; } /** * Sets the javaAttributeNames attribute value. * * @param javaAttributeNames The javaAttributeNames to set. */ protected void setJavaAttributeNames(List<String> javaAttributeNames) { this.javaAttributeNames = javaAttributeNames; } protected String getJavaAttributeName(int index) { return javaAttributeNames.get(index); } /** * Returns the number of attributes this object is holing * * @return the count of attributes this holding */ protected int size() { return javaAttributeNames.size(); } } /** * Performs the reconciliation on origin entries using the data from the {@link ReconciliationBlock} parameter * * @param entries origin entries * @param reconBlock reconciliation data * @param errorMessages a non-null list onto which error messages will be appended. This list will be modified by reference. * @see org.kuali.kfs.gl.batch.service.ReconciliationService#reconcile(java.util.Iterator, * org.kuali.kfs.gl.batch.service.impl.ReconciliationBlock, java.util.List) */ public void reconcile(Iterator<OriginEntryFull> entries, ReconciliationBlock reconBlock, List<Message> errorMessages) { List<ColumnReconciliation> columns = reconBlock.getColumns(); int numEntriesSuccessfullyLoaded = 0; // this value gets incremented every time the hasNext method of the iterator is called int numEntriesAttemptedToLoad = 1; // precompute the DB -> java name mappings so that we don't have to recompute them once for each row List<JavaAttributeAugmentedColumnReconciliation> javaAttributeNames = resolveJavaAttributeNames(columns); KualiDecimal[] columnSums = createColumnSumsArray(columns.size()); // because of the way the OriginEntryFileIterator works (which is likely to be the type passed in as a parameter), // there are 2 primary causes of exceptions to be thrown by the Iterator.hasNext method: // // - Underlying IO exception, this is a fatal error (i.e. we no longer attempt to continue parsing the file) // - Misformatted origin entry line, which is not fatal (i.e. continue parsing the file and report further misformatted // lines), but if it occurs, we don't want to do the final reconciliation step after this loop // operator short-circuiting is utilized to ensure that if there's a fatal error then we don't try to keep reading boolean entriesFullyIterated = false; // set to true if there's a problem parsing origin entry line(s) boolean loadExceptionEncountered = false; while (!entriesFullyIterated) { try { while (entries.hasNext()) { numEntriesAttemptedToLoad++; OriginEntryFull entry = entries.next(); for (int c = 0; c < columns.size(); c++) { // this is for each definition of the "S" line in the reconciliation file KualiDecimal columnValue = KualiDecimal.ZERO; for (int f = 0; f < javaAttributeNames.get(c).size(); f++) { String javaAttributeName = javaAttributeNames.get(c).getJavaAttributeName(f); Object fieldValue = entry.getFieldValue(javaAttributeName); if (fieldValue == null) { // what to do about nulls? } else { if (TypeUtils.isIntegralClass(fieldValue.getClass()) || TypeUtils.isDecimalClass(fieldValue.getClass())) { KualiDecimal castValue; if (fieldValue instanceof KualiDecimal) { castValue = (KualiDecimal) fieldValue; } else { castValue = new KualiDecimal(fieldValue.toString()); } columnValue = columnValue.add(castValue); } else { throw new LoadException("The value for " + columns.get(c).getTokenizedFieldNames()[f] + " is not a numeric value."); } } } columnSums[c] = columnSums[c].add(columnValue); } numEntriesSuccessfullyLoaded++; } } catch (LoadException e) { loadExceptionEncountered = true; LOG.error("Line " + numEntriesAttemptedToLoad + " parse error: " + e.getMessage(), e); Message newMessage = new Message("Line " + numEntriesAttemptedToLoad + " parse error: " + e.getMessage(), Message.TYPE_FATAL); errorMessages.add(newMessage); numEntriesAttemptedToLoad++; continue; } catch (Exception e) { // entriesFullyIterated will stay false when we break out // encountered a potentially serious problem, abort reading of the data LOG.error("Error encountered trying to iterate through origin entry iterator", e); Message newMessage = new Message(e.getMessage(), Message.TYPE_FATAL); errorMessages.add(newMessage); break; } entriesFullyIterated = true; } if (entriesFullyIterated) { if (loadExceptionEncountered) { // generate a message saying reconcilation check did not continue LOG.error("Reconciliation check failed because some origin entry lines could not be parsed."); Message newMessage = new Message("Reconciliation check failed because some origin entry lines could not be parsed.", Message.TYPE_FATAL); errorMessages.add(newMessage); } else { // see if the rowcount matches if (numEntriesSuccessfullyLoaded != reconBlock.getRowCount()) { Message newMessage = generateRowCountMismatchMessage(reconBlock, numEntriesSuccessfullyLoaded); errorMessages.add(newMessage); } // now that we've computed the statistics for all of the origin entries in the iterator, // compare the actual statistics (in the columnSums array) with the stats provided in the // reconciliation file (in the "columns" List attribute reconBlock object). Both of these // array/lists should have the same size for (int i = 0; i < columns.size(); i++) { if (!columnSums[i].equals(columns.get(i).getDollarAmount())) { Message newMessage = generateColumnSumErrorMessage(columns.get(i), columnSums[i]); errorMessages.add(newMessage); } } } } } /** * Generates the error message for the sum of column(s) not matching the reconciliation value * * @param column the column reconciliation data (recall that this "column" can be the sum of several columns) * @param actualValue the value of the column(s) * @return the message */ protected Message generateColumnSumErrorMessage(ColumnReconciliation column, KualiDecimal actualValue) { // TODO: if the kualiConfiguration service were to implement message params from ApplicationResources.properties, this would // be ideal for that StringBuilder buf = new StringBuilder(); buf.append("Reconciliation failed for field value(s) \""); buf.append(column.getFieldName()); buf.append("\", expected "); buf.append(column.getDollarAmount()); buf.append(", found value "); buf.append(actualValue); buf.append("."); Message newMessage = new Message(buf.toString(), Message.TYPE_FATAL); return newMessage; } /** * Generates the error message for the number of entries reconciled being unequal to the expected value * * @param block The file reconciliation data * @param actualRowCount the number of rows encountered * @return the message */ protected Message generateRowCountMismatchMessage(ReconciliationBlock block, int actualRowCount) { // TODO: if the kualiConfiguration service were to implement message params from ApplicationResources.properties, this would // be ideal for that StringBuilder buf = new StringBuilder(); buf.append("Reconciliation failed because an incorrect number of origin entry rows were successfully parsed. Expected "); buf.append(block.getRowCount()); buf.append(" row(s), parsed "); buf.append(actualRowCount); buf.append(" row(s)."); Message newMessage = new Message(buf.toString(), Message.TYPE_FATAL); return newMessage; } /** * Performs basic checking to ensure that values are set up so that reconciliation can proceed * * @param columns the columns generated by the {@link org.kuali.kfs.gl.batch.service.ReconciliationParserService} * @param javaAttributeNames the java attribute names corresponding to each field in columns. (see * {@link #resolveJavaAttributeNames(List)}) * @param columnSums a list of KualiDecimals used to store column sums as reconciliation iterates through the origin entries * @param errorMessages a list to which error messages will be appended. * @return true if there are no problems, false otherwise */ protected boolean performSanityChecks(List<ColumnReconciliation> columns, List<JavaAttributeAugmentedColumnReconciliation> javaAttributeNames, KualiDecimal[] columnSums, List<Message> errorMessages) { boolean success = true; if (javaAttributeNames.size() != columnSums.length || javaAttributeNames.size() != columns.size()) { // sanity check errorMessages.add(new Message("Reconciliation error: Sizes of lists do not match", Message.TYPE_FATAL)); success = false; } for (int i = 0; i < columns.size(); i++) { if (columns.get(i).getTokenizedFieldNames().length != javaAttributeNames.get(i).size()) { errorMessages.add(new Message("Reconciliation error: Error tokenizing column elements. The number of database fields and java fields do not match.", Message.TYPE_FATAL)); success = false; } for (int fieldIdx = 0; fieldIdx < javaAttributeNames.get(i).size(); i++) { if (StringUtils.isBlank(javaAttributeNames.get(i).getJavaAttributeName(fieldIdx))) { errorMessages.add(new Message("Reconciliation error: javaAttributeName is blank for DB column: " + columns.get(i).getTokenizedFieldNames()[fieldIdx], Message.TYPE_FATAL)); success = false; } } } return success; } /** * Creates an array of {@link KualiDecimal}s of a given size, and initializes all elements to {@link KualiDecimal#ZERO} * * @param size the size of the constructed array * @return the array, all initialized to {@link KualiDecimal#ZERO} */ protected KualiDecimal[] createColumnSumsArray(int size) { KualiDecimal[] array = new KualiDecimal[size]; for (int i = 0; i < array.length; i++) { array[i] = KualiDecimal.ZERO; } return array; } /** * Resolves a mapping between the database columns and the java attribute name (i.e. bean property names) * * @param columns columns parsed by the {@link org.kuali.kfs.gl.batch.service.ReconciliationParserService} * @return a list of {@link JavaAttributeAugmentedColumnReconciliation} (see class description) objects. The returned list will * have the same size as the parameter, and each element in one list corresponds to the element at the same position in * the other list */ protected List<JavaAttributeAugmentedColumnReconciliation> resolveJavaAttributeNames(List<ColumnReconciliation> columns) { List<JavaAttributeAugmentedColumnReconciliation> attributes = new ArrayList<JavaAttributeAugmentedColumnReconciliation>(); for (ColumnReconciliation column : columns) { JavaAttributeAugmentedColumnReconciliation c = new JavaAttributeAugmentedColumnReconciliation(); c.setColumnReconciliation(column); c.setJavaAttributeNames(reconciliationDao.convertDBColumnNamesToJavaName(getOriginEntryClass(), column.getTokenizedFieldNames(), true)); attributes.add(c); } return attributes; } /** * Gets the reconciliationDao attribute. * * @return Returns the reconciliationDao. */ protected ReconciliationDao getReconciliationDao() { return reconciliationDao; } /** * Sets the reconciliationDao attribute value. * * @param reconciliationDao The reconciliationDao to set. */ public void setReconciliationDao(ReconciliationDao reconciliationDao) { this.reconciliationDao = reconciliationDao; } /** * Gets the originEntryClass attribute. * * @return Returns the originEntryClass. */ protected Class<? extends OriginEntryFull> getOriginEntryClass() { return originEntryClass; } /** * Sets the originEntryClass attribute value. * * @param originEntryClass The originEntryClass to set. */ public void setOriginEntryClass(Class<? extends OriginEntryFull> originEntryClass) { this.originEntryClass = originEntryClass; } }