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    
017    package org.kuali.kfs.sys.businessobject;
018    
019    import static org.kuali.kfs.sys.KFSKeyConstants.AccountingLineParser.ERROR_INVALID_FILE_FORMAT;
020    import static org.kuali.kfs.sys.KFSKeyConstants.AccountingLineParser.ERROR_INVALID_PROPERTY_VALUE;
021    import static org.kuali.kfs.sys.KFSPropertyConstants.ACCOUNT_NUMBER;
022    import static org.kuali.kfs.sys.KFSPropertyConstants.AMOUNT;
023    import static org.kuali.kfs.sys.KFSPropertyConstants.CHART_OF_ACCOUNTS_CODE;
024    import static org.kuali.kfs.sys.KFSPropertyConstants.FINANCIAL_OBJECT_CODE;
025    import static org.kuali.kfs.sys.KFSPropertyConstants.FINANCIAL_SUB_OBJECT_CODE;
026    import static org.kuali.kfs.sys.KFSPropertyConstants.ORGANIZATION_REFERENCE_ID;
027    import static org.kuali.kfs.sys.KFSPropertyConstants.OVERRIDE_CODE;
028    import static org.kuali.kfs.sys.KFSPropertyConstants.POSTING_YEAR;
029    import static org.kuali.kfs.sys.KFSPropertyConstants.PROJECT_CODE;
030    import static org.kuali.kfs.sys.KFSPropertyConstants.SEQUENCE_NUMBER;
031    import static org.kuali.kfs.sys.KFSPropertyConstants.SUB_ACCOUNT_NUMBER;
032    
033    import java.io.BufferedReader;
034    import java.io.IOException;
035    import java.io.InputStream;
036    import java.io.InputStreamReader;
037    import java.lang.reflect.InvocationTargetException;
038    import java.util.ArrayList;
039    import java.util.HashMap;
040    import java.util.List;
041    import java.util.Map;
042    import java.util.Map.Entry;
043    
044    import org.apache.commons.lang.StringUtils;
045    import org.kuali.kfs.coa.service.AccountService;
046    import org.kuali.kfs.sys.KFSConstants;
047    import org.kuali.kfs.sys.KFSKeyConstants;
048    import org.kuali.kfs.sys.KFSPropertyConstants;
049    import org.kuali.kfs.sys.context.SpringContext;
050    import org.kuali.kfs.sys.document.AccountingDocument;
051    import org.kuali.kfs.sys.exception.AccountingLineParserException;
052    import org.kuali.rice.kns.exception.InfrastructureException;
053    import org.kuali.rice.kns.service.BusinessObjectDictionaryService;
054    import org.kuali.rice.kns.service.DataDictionaryService;
055    import org.kuali.rice.kns.util.GlobalVariables;
056    import org.kuali.rice.kns.util.ObjectUtils;
057    import org.kuali.rice.kns.web.format.FormatException;
058    
059    /**
060     * Base class for parsing serialized <code>AccountingLine</code>s for <code>TransactionalDocument</code>s
061     */
062    public class AccountingLineParserBase implements AccountingLineParser {
063        protected static final String[] DEFAULT_FORMAT = { CHART_OF_ACCOUNTS_CODE, ACCOUNT_NUMBER, SUB_ACCOUNT_NUMBER, FINANCIAL_OBJECT_CODE, FINANCIAL_SUB_OBJECT_CODE, PROJECT_CODE, ORGANIZATION_REFERENCE_ID, AMOUNT };
064        private String fileName;
065        private Integer lineNo = 0;
066    
067        /**
068         * @see org.kuali.rice.kns.bo.AccountingLineParser#getSourceAccountingLineFormat()
069         */
070        public String[] getSourceAccountingLineFormat() {
071            return removeChartFromFormatIfNeeded(DEFAULT_FORMAT);
072        }
073    
074        /**
075         * @see org.kuali.rice.kns.bo.AccountingLineParser#getTargetAccountingLineFormat()
076         */
077        public String[] getTargetAccountingLineFormat() {
078            return removeChartFromFormatIfNeeded(DEFAULT_FORMAT);
079        }
080    
081        /**
082         * If accounts can cross charts, returns the given format; 
083         * otherwise returns the format with ChartOfAccountsCode field removed.
084         */
085        public String[] removeChartFromFormatIfNeeded(String[] format) {
086            if (SpringContext.getBean(AccountService.class).accountsCanCrossCharts()) {
087                return format;
088            }
089            
090            // if accounts can't cross charts, exclude ChartOfAccountsCode field from the format
091            String[] formatNoChart = new String[format.length-1];
092            int idx = 0;
093            for (int i=0; i<format.length; i++) {
094                if (format[i].equals(CHART_OF_ACCOUNTS_CODE)) 
095                    continue;
096                else {
097                    formatNoChart[idx] = format[i];
098                    idx++;
099                }
100            }
101            return formatNoChart;
102        }
103        
104        /**
105         * @see org.kuali.rice.kns.bo.AccountingLineParser#getExpectedAccountingLineFormatAsString(java.lang.Class)
106         */
107        public String getExpectedAccountingLineFormatAsString(Class<? extends AccountingLine> accountingLineClass) {
108            StringBuffer sb = new StringBuffer();
109            boolean first = true;
110            for (String attributeName : chooseFormat(accountingLineClass)) {
111                if (!first) {
112                    sb.append(",");
113                }
114                else {
115                    first = false;
116                }
117                sb.append(retrieveAttributeLabel(accountingLineClass, attributeName));
118            }
119            return sb.toString();
120        }
121    
122        /**
123         * @see org.kuali.rice.kns.bo.AccountingLineParser#parseSourceAccountingLine(org.kuali.rice.kns.document.TransactionalDocument,
124         *      java.lang.String)
125         */
126        public SourceAccountingLine parseSourceAccountingLine(AccountingDocument transactionalDocument, String sourceAccountingLineString) {
127            Class sourceAccountingLineClass = getSourceAccountingLineClass(transactionalDocument);
128            SourceAccountingLine sourceAccountingLine = (SourceAccountingLine) populateAccountingLine(transactionalDocument, sourceAccountingLineClass, sourceAccountingLineString, parseAccountingLine(sourceAccountingLineClass, sourceAccountingLineString), transactionalDocument.getNextSourceLineNumber());
129            return sourceAccountingLine;
130        }
131        
132        /**
133         * Given a document, determines what class the source lines of that document uses
134         * @param accountingDocument the document to find the class of the source lines for
135         * @return the class of the source lines
136         */
137        protected Class getSourceAccountingLineClass(final AccountingDocument accountingDocument) {
138            return accountingDocument.getSourceAccountingLineClass();
139        }
140    
141        /**
142         * @see org.kuali.rice.kns.bo.AccountingLineParser#parseTargetAccountingLine(org.kuali.rice.kns.document.TransactionalDocument,
143         *      java.lang.String)
144         */
145        public TargetAccountingLine parseTargetAccountingLine(AccountingDocument transactionalDocument, String targetAccountingLineString) {
146            Class targetAccountingLineClass = getTargetAccountingLineClass(transactionalDocument);
147            TargetAccountingLine targetAccountingLine = (TargetAccountingLine) populateAccountingLine(transactionalDocument, targetAccountingLineClass, targetAccountingLineString, parseAccountingLine(targetAccountingLineClass, targetAccountingLineString), transactionalDocument.getNextTargetLineNumber());
148            return targetAccountingLine;
149        }
150        
151        /**
152         * Given a document, determines what class that document uses for target accounting lines
153         * @param accountingDocument the document to determine the target accounting line class for
154         * @return the class of the target lines for the given document
155         */
156        protected Class getTargetAccountingLineClass(final AccountingDocument accountingDocument) {
157            return accountingDocument.getTargetAccountingLineClass();
158        }
159    
160        /**
161         * Populates a source/target line with values
162         * 
163         * @param transactionalDocument
164         * @param accountingLineClass
165         * @param accountingLineAsString
166         * @param attributeValueMap
167         * @param sequenceNumber
168         * @return AccountingLine
169         */
170        protected AccountingLine populateAccountingLine(AccountingDocument transactionalDocument, Class<? extends AccountingLine> accountingLineClass, String accountingLineAsString, Map<String, String> attributeValueMap, Integer sequenceNumber) {
171    
172            putCommonAttributesInMap(attributeValueMap, transactionalDocument, sequenceNumber);
173    
174            // create line and populate fields
175            AccountingLine accountingLine;
176    
177            try {
178                accountingLine = (AccountingLine) accountingLineClass.newInstance();
179                
180                // perform custom line population
181                if (SourceAccountingLine.class.isAssignableFrom(accountingLineClass)) {
182                    performCustomSourceAccountingLinePopulation(attributeValueMap, (SourceAccountingLine) accountingLine, accountingLineAsString);
183                }
184                else if (TargetAccountingLine.class.isAssignableFrom(accountingLineClass)) {
185                    performCustomTargetAccountingLinePopulation(attributeValueMap, (TargetAccountingLine) accountingLine, accountingLineAsString);
186                }
187                else {
188                    throw new IllegalArgumentException("invalid (unknown) accounting line type: " + accountingLineClass);
189                }
190                
191                for (Entry<String, String> entry : attributeValueMap.entrySet()) {
192                    try {
193                        try {
194                            Class entryType = ObjectUtils.easyGetPropertyType(accountingLine, entry.getKey());
195                            if (String.class.isAssignableFrom(entryType)) {
196                                entry.setValue(entry.getValue().toUpperCase());
197                            }
198                            ObjectUtils.setObjectProperty(accountingLine, entry.getKey(), entryType, entry.getValue());
199                        }
200                        catch (IllegalArgumentException e) {
201                            throw new InfrastructureException("unable to complete accounting line population.", e);
202                        }
203                    }
204                    catch (FormatException e) {
205                        String[] errorParameters = { entry.getValue().toString(), retrieveAttributeLabel(accountingLine.getClass(), entry.getKey()), accountingLineAsString };
206                        // KULLAB-408
207                        GlobalVariables.getMessageMap().putError(KFSConstants.ACCOUNTING_LINE_ERRORS, ERROR_INVALID_PROPERTY_VALUE, entry.getValue().toString(), entry.getKey(), accountingLineAsString + "  : Line Number " + lineNo.toString());
208                        throw new AccountingLineParserException("invalid '" + entry.getKey() + "=" + entry.getValue() + "for " + accountingLineAsString, ERROR_INVALID_PROPERTY_VALUE, errorParameters);
209                    }
210                }
211                
212                // override chart code if accounts can't cross charts
213                SpringContext.getBean(AccountService.class).populateAccountingLineChartIfNeeded(accountingLine);            
214            }
215            catch (SecurityException e) {
216                throw new InfrastructureException("unable to complete accounting line population.", e);
217            }
218            catch (NoSuchMethodException e) {
219                throw new InfrastructureException("unable to complete accounting line population.", e);
220            }
221            catch (IllegalAccessException e) {
222                throw new InfrastructureException("unable to complete accounting line population.", e);
223            }
224            catch (InvocationTargetException e) {
225                throw new InfrastructureException("unable to complete accounting line population.", e);
226            }
227            catch (InstantiationException e) {
228                throw new InfrastructureException("unable to complete accounting line population.", e);
229            }
230    
231            // force input to uppercase
232            SpringContext.getBean(BusinessObjectDictionaryService.class).performForceUppercase(accountingLine);
233            accountingLine.refresh();
234    
235            return accountingLine;
236        }
237    
238        /**
239         * Places fields common to both source/target accounting lines in the attribute map
240         * 
241         * @param attributeValueMap
242         * @param document
243         * @param sequenceNumber
244         */
245        protected void putCommonAttributesInMap(Map<String, String> attributeValueMap, AccountingDocument document, Integer sequenceNumber) {
246            attributeValueMap.put(KFSPropertyConstants.DOCUMENT_NUMBER, document.getDocumentNumber());
247            attributeValueMap.put(POSTING_YEAR, document.getPostingYear().toString());
248            attributeValueMap.put(SEQUENCE_NUMBER, sequenceNumber.toString());
249        }
250    
251        /**
252         * Parses the csv line
253         * 
254         * @param accountingLineClass
255         * @param lineToParse
256         * @return Map containing accounting line attribute,value pairs
257         */
258        protected Map<String, String> parseAccountingLine(Class<? extends AccountingLine> accountingLineClass, String lineToParse) {
259            if (StringUtils.isNotBlank(fileName) && !StringUtils.lowerCase(fileName).endsWith(".csv")) {
260                throw new AccountingLineParserException("unsupported file format: " + fileName, ERROR_INVALID_FILE_FORMAT, fileName);
261            }
262            String[] attributes = chooseFormat(accountingLineClass);
263            String[] attributeValues = StringUtils.splitPreserveAllTokens(lineToParse, ",");
264    
265            Map<String, String> attributeValueMap = new HashMap<String, String>();
266    
267            for (int i = 0; i < Math.min(attributeValues.length, attributes.length); i++) {
268                attributeValueMap.put(attributes[i], attributeValues[i]);
269            }
270    
271            return attributeValueMap;
272        }
273    
274        /**
275         * Should be voerriden by documents to perform any additional <code>SourceAccountingLine</code> population
276         * 
277         * @param attributeValueMap
278         * @param sourceAccountingLine
279         * @param accountingLineAsString
280         */
281        protected void performCustomSourceAccountingLinePopulation(Map<String, String> attributeValueMap, SourceAccountingLine sourceAccountingLine, String accountingLineAsString) {
282        }
283    
284        /**
285         * Should be overridden by documents to perform any additional <code>TargetAccountingLine</code> attribute population
286         * 
287         * @param attributeValueMap
288         * @param targetAccountingLine
289         * @param accountingLineAsString
290         */
291        protected void performCustomTargetAccountingLinePopulation(Map<String, String> attributeValueMap, TargetAccountingLine targetAccountingLine, String accountingLineAsString) {
292        }
293    
294        /**
295         * Calls the appropriate parseAccountingLine method
296         * 
297         * @param stream
298         * @param transactionalDocument
299         * @param isSource
300         * @return List
301         */
302        protected List<AccountingLine> importAccountingLines(String fileName, InputStream stream, AccountingDocument transactionalDocument, boolean isSource) {
303            List<AccountingLine> importedAccountingLines = new ArrayList<AccountingLine>();
304            this.fileName = fileName;
305            BufferedReader br = new BufferedReader(new InputStreamReader(stream));
306    
307            try {
308                String accountingLineAsString = null;
309                lineNo = 0;
310                while ((accountingLineAsString = br.readLine()) != null) {
311                    lineNo++;
312                    AccountingLine accountingLine = null;
313    
314                    try {
315                        if (isSource) {
316                            accountingLine = parseSourceAccountingLine(transactionalDocument, accountingLineAsString);
317                        }
318                        else {
319                            accountingLine = parseTargetAccountingLine(transactionalDocument, accountingLineAsString);
320                        }
321    
322                        validateImportedAccountingLine(accountingLine, accountingLineAsString);
323                        importedAccountingLines.add(accountingLine);
324                    }
325                    catch (AccountingLineParserException e) {
326                        GlobalVariables.getMessageMap().putError((isSource ? "sourceAccountingLines" : "targetAccountingLines"), KFSKeyConstants.ERROR_ACCOUNTING_DOCUMENT_ACCOUNTING_LINE_IMPORT_GENERAL, new String[] { e.getMessage() });
327                    }
328                }
329            }
330            catch (IOException e) {
331                throw new InfrastructureException("unable to readLine from bufferReader in accountingLineParserBase", e);
332            }
333            finally {
334                try {
335                    br.close();
336                }
337                catch (IOException e) {
338                    throw new InfrastructureException("unable to close bufferReader in accountingLineParserBase", e);
339                }
340            }
341    
342            return importedAccountingLines;
343        }
344    
345        /**
346         * @see org.kuali.rice.kns.bo.AccountingLineParser#importSourceAccountingLines(java.io.InputStream,
347         *      org.kuali.rice.kns.document.TransactionalDocument)
348         */
349        public final List importSourceAccountingLines(String fileName, InputStream stream, AccountingDocument document) {
350            return importAccountingLines(fileName, stream, document, true);
351        }
352    
353        /**
354         * @see org.kuali.rice.kns.bo.AccountingLineParser#importTargetAccountingLines(java.io.InputStream,
355         *      org.kuali.rice.kns.document.TransactionalDocument)
356         */
357        public final List importTargetAccountingLines(String fileName, InputStream stream, AccountingDocument document) {
358            return importAccountingLines(fileName, stream, document, false);
359        }
360    
361        /**
362         * performs any additional accounting line validation
363         * 
364         * @param line
365         * @param accountingLineAsString
366         * @throws AccountingLineParserException
367         */
368        protected void validateImportedAccountingLine(AccountingLine line, String accountingLineAsString) throws AccountingLineParserException {
369            // This check isn't done for the web UI because the code is never input from the user and doesn't correspond to a displayed
370            // property that could be error highlighted. Throwing an exception here follows the convention of TooFewFieldsException
371            // and the unchecked NumberFormatException, altho todo: reconsider design, e.g., KULFDBCK-478
372            String overrideCode = line.getOverrideCode();
373            if (!AccountingLineOverride.isValidCode(overrideCode)) {
374                String[] errorParameters = { overrideCode, retrieveAttributeLabel(line.getClass(), OVERRIDE_CODE), accountingLineAsString };
375                throw new AccountingLineParserException("invalid overrride code '" + overrideCode + "' for:" + accountingLineAsString, ERROR_INVALID_PROPERTY_VALUE, errorParameters);
376            }
377        }
378    
379        protected String retrieveAttributeLabel(Class clazz, String attributeName) {
380            String label = SpringContext.getBean(DataDictionaryService.class).getAttributeLabel(clazz, attributeName);
381            if (StringUtils.isBlank(label)) {
382                label = attributeName;
383            }
384            return label;
385        }
386    
387        protected String[] chooseFormat(Class<? extends AccountingLine> accountingLineClass) {
388            String[] format = null;
389            if (SourceAccountingLine.class.isAssignableFrom(accountingLineClass)) {
390                format = getSourceAccountingLineFormat();
391            }
392            else if (TargetAccountingLine.class.isAssignableFrom(accountingLineClass)) {
393                format = getTargetAccountingLineFormat();
394            }
395            else {
396                throw new IllegalStateException("unknow accounting line class: " + accountingLineClass);
397            }
398            return format;
399        }
400    }