001    /*
002     * Copyright 2011 The Kuali Foundation.
003     * 
004     * Licensed under the Educational Community License, Version 2.0 (the "License");
005     * you may not use this file except in compliance with the License.
006     * You may obtain a copy of the License at
007     * 
008     * http://www.opensource.org/licenses/ecl2.php
009     * 
010     * Unless required by applicable law or agreed to in writing, software
011     * distributed under the License is distributed on an "AS IS" BASIS,
012     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013     * See the License for the specific language governing permissions and
014     * limitations under the License.
015     */
016    package org.kuali.kfs.module.ld.batch.service.impl;
017    
018    import java.util.ArrayList;
019    import java.util.Iterator;
020    import java.util.List;
021    
022    import org.apache.commons.lang.StringUtils;
023    import org.kuali.kfs.gl.batch.dataaccess.ReconciliationDao;
024    import org.kuali.kfs.gl.batch.service.impl.ColumnReconciliation;
025    import org.kuali.kfs.gl.batch.service.impl.ReconciliationBlock;
026    import org.kuali.kfs.gl.businessobject.OriginEntryFull;
027    import org.kuali.kfs.gl.exception.LoadException;
028    import org.kuali.kfs.module.ld.batch.service.ReconciliationService;
029    import org.kuali.kfs.module.ld.businessobject.LaborOriginEntry;
030    import org.kuali.kfs.sys.Message;
031    import org.kuali.rice.kns.util.KualiDecimal;
032    import org.kuali.rice.kns.util.TypeUtils;
033    import org.springframework.transaction.annotation.Transactional;
034    
035    /**
036     * Default implementation of ReconciliationService
037     */
038    @Transactional
039    public class ReconciliationServiceImpl implements ReconciliationService {
040        private static org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(ReconciliationServiceImpl.class);
041    
042        private ReconciliationDao reconciliationDao;
043        private Class<? extends OriginEntryFull> originEntryClass;
044    
045        /**
046         * A wrapper around {@link ColumnReconciliation} objects to provide it with information specific to the java beans representing
047         * each BO. <br/><br/> In the default implementation of {@link org.kuali.kfs.gl.batch.service.ReconciliationParserService}, each
048         * {@link ColumnReconciliation} object may actually represent the sum of multiple fields across all origin entries (i.e.
049         * ColumnReconciliation.getTokenizedFieldNames().length may be > 1). <br/><br/> Furthermore, the parser service returns
050         * database field names as the identifier. This service requires the use of java bean names, so this class is used to maintain a
051         * mapping between the DB names (in columnReconciliation.getTokenizedFieldNames()) and the java bean names (in
052         * javaAttributeNames). These lists/arrays are the same size, and each element at the same position in both lists are mapped to
053         * each other.
054         */
055        protected class JavaAttributeAugmentedColumnReconciliation {
056            protected ColumnReconciliation columnReconciliation;
057            protected List<String> javaAttributeNames;
058    
059            protected JavaAttributeAugmentedColumnReconciliation() {
060                columnReconciliation = null;
061                javaAttributeNames = null;
062            }
063    
064            /**
065             * Gets the columnReconciliation attribute.
066             * 
067             * @return Returns the columnReconciliation.
068             */
069            protected ColumnReconciliation getColumnReconciliation() {
070                return columnReconciliation;
071            }
072    
073            /**
074             * Sets the columnReconciliation attribute value.
075             * 
076             * @param columnReconciliation The columnReconciliation to set.
077             */
078            protected void setColumnReconciliation(ColumnReconciliation columnReconciliation) {
079                this.columnReconciliation = columnReconciliation;
080            }
081    
082            /**
083             * Sets the javaAttributeNames attribute value.
084             * 
085             * @param javaAttributeNames The javaAttributeNames to set.
086             */
087            protected void setJavaAttributeNames(List<String> javaAttributeNames) {
088                this.javaAttributeNames = javaAttributeNames;
089            }
090    
091            protected String getJavaAttributeName(int index) {
092                return javaAttributeNames.get(index);
093            }
094    
095            /**
096             * Returns the number of attributes this object is holing
097             * 
098             * @return the count of attributes this holding
099             */
100            protected int size() {
101                return javaAttributeNames.size();
102            }
103        }
104    
105        /**
106         * Performs the reconciliation on origin entries using the data from the {@link ReconciliationBlock} parameter
107         * 
108         * @param entries origin entries
109         * @param reconBlock reconciliation data
110         * @param errorMessages a non-null list onto which error messages will be appended. This list will be modified by reference.
111         * @see org.kuali.kfs.gl.batch.service.ReconciliationService#reconcile(java.util.Iterator,
112         *      org.kuali.kfs.gl.batch.service.impl.ReconciliationBlock, java.util.List)
113         */
114        public void reconcile(Iterator<LaborOriginEntry> entries, ReconciliationBlock reconBlock, List<Message> errorMessages) {
115            List<ColumnReconciliation> columns = reconBlock.getColumns();
116    
117            int numEntriesSuccessfullyLoaded = 0;
118    
119            // this value gets incremented every time the hasNext method of the iterator is called
120            int numEntriesAttemptedToLoad = 1;
121    
122            // precompute the DB -> java name mappings so that we don't have to recompute them once for each row
123            List<JavaAttributeAugmentedColumnReconciliation> javaAttributeNames = resolveJavaAttributeNames(columns);
124            KualiDecimal[] columnSums = createColumnSumsArray(columns.size());
125    
126            // because of the way the OriginEntryFileIterator works (which is likely to be the type passed in as a parameter),
127            // there are 2 primary causes of exceptions to be thrown by the Iterator.hasNext method:
128            // 
129            // - Underlying IO exception, this is a fatal error (i.e. we no longer attempt to continue parsing the file)
130            // - Misformatted origin entry line, which is not fatal (i.e. continue parsing the file and report further misformatted
131            // lines), but if it occurs, we don't want to do the final reconciliation step after this loop
132    
133            // operator short-circuiting is utilized to ensure that if there's a fatal error then we don't try to keep reading
134    
135            boolean entriesFullyIterated = false;
136    
137            // set to true if there's a problem parsing origin entry line(s)
138            boolean loadExceptionEncountered = false;
139    
140            while (!entriesFullyIterated) {
141                try {
142                    while (entries.hasNext()) {
143                        numEntriesAttemptedToLoad++;
144                        OriginEntryFull entry = entries.next();
145                        for (int c = 0; c < columns.size(); c++) {
146                            // this is for each definition of the "S" line in the reconciliation file
147                            KualiDecimal columnValue = KualiDecimal.ZERO;
148    
149                            for (int f = 0; f < javaAttributeNames.get(c).size(); f++) {
150                                String javaAttributeName = javaAttributeNames.get(c).getJavaAttributeName(f);
151                                Object fieldValue = entry.getFieldValue(javaAttributeName);
152    
153                                if (fieldValue == null) {
154                                    // what to do about nulls?
155                                }
156                                else {
157                                    if (TypeUtils.isIntegralClass(fieldValue.getClass()) || TypeUtils.isDecimalClass(fieldValue.getClass())) {
158                                        KualiDecimal castValue;
159                                        if (fieldValue instanceof KualiDecimal) {
160                                            castValue = (KualiDecimal) fieldValue;
161                                        }
162                                        else {
163                                            castValue = new KualiDecimal(fieldValue.toString());
164                                        }
165                                        columnValue = columnValue.add(castValue);
166                                    }
167                                    else {
168                                        throw new LoadException("The value for " + columns.get(c).getTokenizedFieldNames()[f] + " is not a numeric value.");
169                                    }
170                                }
171                            }
172                            columnSums[c] = columnSums[c].add(columnValue);
173                        }
174                        numEntriesSuccessfullyLoaded++;
175                    }
176                }
177                catch (LoadException e) {
178                    loadExceptionEncountered = true;
179                    LOG.error("Line " + numEntriesAttemptedToLoad + " parse error: " + e.getMessage(), e);
180                    Message newMessage = new Message("Line " + numEntriesAttemptedToLoad + " parse error: " + e.getMessage(), Message.TYPE_FATAL);
181                    errorMessages.add(newMessage);
182    
183                    numEntriesAttemptedToLoad++;
184                    continue;
185                }
186                catch (Exception e) {
187                    // entriesFullyIterated will stay false when we break out
188    
189                    // encountered a potentially serious problem, abort reading of the data
190                    LOG.error("Error encountered trying to iterate through origin entry iterator", e);
191    
192                    Message newMessage = new Message(e.getMessage(), Message.TYPE_FATAL);
193                    errorMessages.add(newMessage);
194    
195                    break;
196                }
197                entriesFullyIterated = true;
198            }
199    
200            if (entriesFullyIterated) {
201                if (loadExceptionEncountered) {
202                    // generate a message saying reconcilation check did not continue
203                    LOG.error("Reconciliation check failed because some origin entry lines could not be parsed.");
204                    Message newMessage = new Message("Reconciliation check failed because some origin entry lines could not be parsed.", Message.TYPE_FATAL);
205                    errorMessages.add(newMessage);
206                }
207                else {
208                    // see if the rowcount matches
209                    if (numEntriesSuccessfullyLoaded != reconBlock.getRowCount()) {
210                        Message newMessage = generateRowCountMismatchMessage(reconBlock, numEntriesSuccessfullyLoaded);
211                        errorMessages.add(newMessage);
212                    }
213    
214                    // now that we've computed the statistics for all of the origin entries in the iterator,
215                    // compare the actual statistics (in the columnSums array) with the stats provided in the
216                    // reconciliation file (in the "columns" List attribute reconBlock object). Both of these
217                    // array/lists should have the same size
218                    for (int i = 0; i < columns.size(); i++) {
219                        if (!columnSums[i].equals(columns.get(i).getDollarAmount())) {
220                            Message newMessage = generateColumnSumErrorMessage(columns.get(i), columnSums[i]);
221                            errorMessages.add(newMessage);
222                        }
223                    }
224                }
225            }
226        }
227    
228        /**
229         * Generates the error message for the sum of column(s) not matching the reconciliation value
230         * 
231         * @param column the column reconciliation data (recall that this "column" can be the sum of several columns)
232         * @param actualValue the value of the column(s)
233         * @return the message
234         */
235        protected Message generateColumnSumErrorMessage(ColumnReconciliation column, KualiDecimal actualValue) {
236            // TODO: if the kualiConfiguration service were to implement message params from ApplicationResources.properties, this would
237            // be ideal for that
238            StringBuilder buf = new StringBuilder();
239            buf.append("Reconciliation failed for field value(s) \"");
240            buf.append(column.getFieldName());
241            buf.append("\", expected ");
242            buf.append(column.getDollarAmount());
243            buf.append(", found value ");
244            buf.append(actualValue);
245            buf.append(".");
246    
247            Message newMessage = new Message(buf.toString(), Message.TYPE_FATAL);
248            return newMessage;
249        }
250    
251        /**
252         * Generates the error message for the number of entries reconciled being unequal to the expected value
253         * 
254         * @param block The file reconciliation data
255         * @param actualRowCount the number of rows encountered
256         * @return the message
257         */
258        protected Message generateRowCountMismatchMessage(ReconciliationBlock block, int actualRowCount) {
259            // TODO: if the kualiConfiguration service were to implement message params from ApplicationResources.properties, this would
260            // be ideal for that
261            StringBuilder buf = new StringBuilder();
262            buf.append("Reconciliation failed because an incorrect number of origin entry rows were successfully parsed.  Expected ");
263            buf.append(block.getRowCount());
264            buf.append(" row(s), parsed ");
265            buf.append(actualRowCount);
266            buf.append(" row(s).");
267    
268            Message newMessage = new Message(buf.toString(), Message.TYPE_FATAL);
269            return newMessage;
270        }
271    
272        /**
273         * Performs basic checking to ensure that values are set up so that reconciliation can proceed
274         * 
275         * @param columns the columns generated by the {@link org.kuali.kfs.gl.batch.service.ReconciliationParserService}
276         * @param javaAttributeNames the java attribute names corresponding to each field in columns. (see
277         *        {@link #resolveJavaAttributeNames(List)})
278         * @param columnSums a list of KualiDecimals used to store column sums as reconciliation iterates through the origin entries
279         * @param errorMessages a list to which error messages will be appended.
280         * @return true if there are no problems, false otherwise
281         */
282        protected boolean performSanityChecks(List<ColumnReconciliation> columns, List<JavaAttributeAugmentedColumnReconciliation> javaAttributeNames, KualiDecimal[] columnSums, List<Message> errorMessages) {
283            boolean success = true;
284    
285            if (javaAttributeNames.size() != columnSums.length || javaAttributeNames.size() != columns.size()) {
286                // sanity check
287                errorMessages.add(new Message("Reconciliation error: Sizes of lists do not match", Message.TYPE_FATAL));
288                success = false;
289            }
290            for (int i = 0; i < columns.size(); i++) {
291                if (columns.get(i).getTokenizedFieldNames().length != javaAttributeNames.get(i).size()) {
292                    errorMessages.add(new Message("Reconciliation error: Error tokenizing column elements.  The number of database fields and java fields do not match.", Message.TYPE_FATAL));
293                    success = false;
294                }
295                for (int fieldIdx = 0; fieldIdx < javaAttributeNames.get(i).size(); i++) {
296                    if (StringUtils.isBlank(javaAttributeNames.get(i).getJavaAttributeName(fieldIdx))) {
297                        errorMessages.add(new Message("Reconciliation error: javaAttributeName is blank for DB column: " + columns.get(i).getTokenizedFieldNames()[fieldIdx], Message.TYPE_FATAL));
298                        success = false;
299                    }
300                }
301            }
302            return success;
303        }
304    
305        /**
306         * Creates an array of {@link KualiDecimal}s of a given size, and initializes all elements to {@link KualiDecimal#ZERO}
307         * 
308         * @param size the size of the constructed array
309         * @return the array, all initialized to {@link KualiDecimal#ZERO}
310         */
311        protected KualiDecimal[] createColumnSumsArray(int size) {
312            KualiDecimal[] array = new KualiDecimal[size];
313            for (int i = 0; i < array.length; i++) {
314                array[i] = KualiDecimal.ZERO;
315            }
316            return array;
317        }
318    
319        /**
320         * Resolves a mapping between the database columns and the java attribute name (i.e. bean property names)
321         * 
322         * @param columns columns parsed by the {@link org.kuali.kfs.gl.batch.service.ReconciliationParserService}
323         * @return a list of {@link JavaAttributeAugmentedColumnReconciliation} (see class description) objects. The returned list will
324         *         have the same size as the parameter, and each element in one list corresponds to the element at the same position in
325         *         the other list
326         */
327        protected List<JavaAttributeAugmentedColumnReconciliation> resolveJavaAttributeNames(List<ColumnReconciliation> columns) {
328            List<JavaAttributeAugmentedColumnReconciliation> attributes = new ArrayList<JavaAttributeAugmentedColumnReconciliation>();
329            for (ColumnReconciliation column : columns) {
330                JavaAttributeAugmentedColumnReconciliation c = new JavaAttributeAugmentedColumnReconciliation();
331                c.setColumnReconciliation(column);
332                c.setJavaAttributeNames(reconciliationDao.convertDBColumnNamesToJavaName(getOriginEntryClass(), column.getTokenizedFieldNames(), true));
333                attributes.add(c);
334            }
335            return attributes;
336        }
337    
338        /**
339         * Gets the reconciliationDao attribute.
340         * 
341         * @return Returns the reconciliationDao.
342         */
343        protected ReconciliationDao getReconciliationDao() {
344            return reconciliationDao;
345        }
346    
347        /**
348         * Sets the reconciliationDao attribute value.
349         * 
350         * @param reconciliationDao The reconciliationDao to set.
351         */
352        public void setReconciliationDao(ReconciliationDao reconciliationDao) {
353            this.reconciliationDao = reconciliationDao;
354        }
355    
356        /**
357         * Gets the originEntryClass attribute.
358         * 
359         * @return Returns the originEntryClass.
360         */
361        protected Class<? extends OriginEntryFull> getOriginEntryClass() {
362            return originEntryClass;
363        }
364    
365        /**
366         * Sets the originEntryClass attribute value.
367         * 
368         * @param originEntryClass The originEntryClass to set.
369         */
370        public void setOriginEntryClass(Class<? extends OriginEntryFull> originEntryClass) {
371            this.originEntryClass = originEntryClass;
372        }
373    }