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.coa.document.validation.impl;
017
018 import java.util.Collection;
019 import java.util.HashMap;
020 import java.util.Map;
021
022 import org.apache.commons.lang.StringUtils;
023 import org.kuali.kfs.coa.businessobject.A21SubAccount;
024 import org.kuali.kfs.coa.businessobject.IndirectCostRecoveryRateDetail;
025 import org.kuali.kfs.coa.businessobject.SubAccount;
026 import org.kuali.kfs.coa.service.SubFundGroupService;
027 import org.kuali.kfs.sys.KFSConstants;
028 import org.kuali.kfs.sys.KFSKeyConstants;
029 import org.kuali.kfs.sys.KFSPropertyConstants;
030 import org.kuali.kfs.sys.context.SpringContext;
031 import org.kuali.kfs.sys.service.UniversityDateService;
032 import org.kuali.rice.kns.document.MaintenanceDocument;
033 import org.kuali.rice.kns.maintenance.rules.MaintenanceDocumentRuleBase;
034 import org.kuali.rice.kns.service.DataDictionaryService;
035 import org.kuali.rice.kns.util.ObjectUtils;
036
037 /**
038 * This class implements the business rules specific to the {@link SubAccount} Maintenance Document.
039 */
040 public class SubAccountRule extends MaintenanceDocumentRuleBase {
041
042 protected static org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(SubAccountRule.class);
043
044 protected SubAccount oldSubAccount;
045 protected SubAccount newSubAccount;
046
047 /**
048 * This performs rules checks on document approve
049 * <ul>
050 * <li>{@link SubAccountRule#setCgAuthorized(boolean)}</li>
051 * <li>{@link SubAccountRule#checkForPartiallyEnteredReportingFields()}</li>
052 * <li>{@link SubAccountRule#checkCgRules(MaintenanceDocument)}</li>
053 * </ul>
054 * This rule fails on business rule failures
055 *
056 * @see org.kuali.rice.kns.maintenance.rules.MaintenanceDocumentRuleBase#processCustomApproveDocumentBusinessRules(org.kuali.rice.kns.document.MaintenanceDocument)
057 */
058 protected boolean processCustomApproveDocumentBusinessRules(MaintenanceDocument document) {
059 LOG.info("Entering processCustomApproveDocumentBusinessRules()");
060
061 // check that all sub-objects whose keys are specified have matching objects in the db
062 boolean success = checkForPartiallyEnteredReportingFields();
063
064 // process CG rules if appropriate
065 success &= checkCgRules(document);
066
067 return success;
068 }
069
070 /**
071 * This performs rules checks on document route
072 * <ul>
073 * <li>{@link SubAccountRule#setCgAuthorized(boolean)}</li>
074 * <li>{@link SubAccountRule#checkForPartiallyEnteredReportingFields()}</li>
075 * <li>{@link SubAccountRule#checkCgRules(MaintenanceDocument)}</li>
076 * </ul>
077 * This rule fails on business rule failures
078 *
079 * @see org.kuali.rice.kns.maintenance.rules.MaintenanceDocumentRuleBase#processCustomRouteDocumentBusinessRules(org.kuali.rice.kns.document.MaintenanceDocument)
080 */
081 protected boolean processCustomRouteDocumentBusinessRules(MaintenanceDocument document) {
082 LOG.info("Entering processCustomRouteDocumentBusinessRules()");
083
084 boolean success = true;
085
086 // check that all sub-objects whose keys are specified have matching objects in the db
087 success &= checkForPartiallyEnteredReportingFields();
088
089 // process CG rules if appropriate
090 success &= checkCgRules(document);
091
092 return success;
093 }
094
095 /**
096 * This performs rules checks on document save
097 * <ul>
098 * <li>{@link SubAccountRule#setCgAuthorized(boolean)}</li>
099 * <li>{@link SubAccountRule#checkForPartiallyEnteredReportingFields()}</li>
100 * <li>{@link SubAccountRule#checkCgRules(MaintenanceDocument)}</li>
101 * </ul>
102 * This rule does not fail on business rule failures
103 *
104 * @see org.kuali.rice.kns.maintenance.rules.MaintenanceDocumentRuleBase#processCustomSaveDocumentBusinessRules(org.kuali.rice.kns.document.MaintenanceDocument)
105 */
106 protected boolean processCustomSaveDocumentBusinessRules(MaintenanceDocument document) {
107
108 boolean success = true;
109
110 LOG.info("Entering processCustomSaveDocumentBusinessRules()");
111
112 // check that all sub-objects whose keys are specified have matching objects in the db
113 success &= checkForPartiallyEnteredReportingFields();
114
115 // process CG rules if appropriate
116 success &= checkCgRules(document);
117
118 return success;
119 }
120
121 /**
122 * This method sets the convenience objects like newAccount and oldAccount, so you have short and easy handles to the new and
123 * old objects contained in the maintenance document. It also calls the BusinessObjectBase.refresh(), which will attempt to load
124 * all sub-objects from the DB by their primary keys, if available.
125 *
126 * @param document - the maintenanceDocument being evaluated
127 */
128 public void setupConvenienceObjects() {
129
130 // setup oldAccount convenience objects, make sure all possible sub-objects are populated
131 oldSubAccount = (SubAccount) super.getOldBo();
132
133 // setup newAccount convenience objects, make sure all possible sub-objects are populated
134 newSubAccount = (SubAccount) super.getNewBo();
135 }
136
137 /**
138 * This checks that the reporting fields are entered altogether or none at all
139 *
140 * @return false if only one reporting field filled out and not all of them, true otherwise
141 */
142 protected boolean checkForPartiallyEnteredReportingFields() {
143
144 LOG.info("Entering checkExistenceAndActive()");
145
146 boolean success = true;
147 boolean allReportingFieldsEntered = false;
148 boolean anyReportingFieldsEntered = false;
149
150 // set a flag if all three reporting fields are filled (this is separated just for readability)
151 if (StringUtils.isNotEmpty(newSubAccount.getFinancialReportChartCode()) && StringUtils.isNotEmpty(newSubAccount.getFinReportOrganizationCode()) && StringUtils.isNotEmpty(newSubAccount.getFinancialReportingCode())) {
152 allReportingFieldsEntered = true;
153 }
154
155 // set a flag if any of the three reporting fields are filled (this is separated just for readability)
156 if (StringUtils.isNotEmpty(newSubAccount.getFinancialReportChartCode()) || StringUtils.isNotEmpty(newSubAccount.getFinReportOrganizationCode()) || StringUtils.isNotEmpty(newSubAccount.getFinancialReportingCode())) {
157 anyReportingFieldsEntered = true;
158 }
159
160 // if any of the three reporting code fields are filled out, all three must be, or none
161 // if any of the three are entered
162 if (anyReportingFieldsEntered && !allReportingFieldsEntered) {
163 putGlobalError(KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_RPTCODE_ALL_FIELDS_IF_ANY_FIELDS);
164 success &= false;
165 }
166
167 return success;
168 }
169
170 /**
171 * This checks to make sure that if cgAuthorized is false it succeeds immediately, otherwise it checks that all the information
172 * for CG is correctly entered and identified including:
173 * <ul>
174 * <li>If the {@link SubFundGroup} isn't for Contracts and Grants then check to make sure that the cost share and ICR fields are
175 * not empty</li>
176 * <li>If it isn't a child of CG, then the SubAccount must be of type ICR</li>
177 * </ul>
178 *
179 * @param document
180 * @return true if the user is not authorized to change CG fields, otherwise it checks the above conditions
181 */
182 protected boolean checkCgRules(MaintenanceDocument document) {
183
184 boolean success = true;
185
186 // short circuit if the parent account is NOT part of a CG fund group
187 boolean a21SubAccountRefreshed = false;
188 if (ObjectUtils.isNotNull(newSubAccount.getAccount())) {
189 if (ObjectUtils.isNotNull(newSubAccount.getAccount().getSubFundGroup())) {
190
191 // compare them, exit if the account isn't for contracts and grants
192 if (!SpringContext.getBean(SubFundGroupService.class).isForContractsAndGrants(newSubAccount.getAccount().getSubFundGroup())) {
193
194 // KULCOA-1116 - Check if CG CS and CG ICR are empty, if not throw an error
195 if (checkCgCostSharingIsEmpty() == false) {
196 putFieldError("a21SubAccount.costShareChartOfAccountCode", KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_NON_FUNDED_ACCT_CS_INVALID, new String[] { SpringContext.getBean(SubFundGroupService.class).getContractsAndGrantsDenotingAttributeLabel(), SpringContext.getBean(SubFundGroupService.class).getContractsAndGrantsDenotingValueForMessage() });
197 success = false;
198 }
199
200 if (checkCgIcrIsEmpty() == false) {
201 putFieldError("a21SubAccount.indirectCostRecoveryTypeCode", KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_NON_FUNDED_ACCT_ICR_INVALID, new String[] { SpringContext.getBean(SubFundGroupService.class).getContractsAndGrantsDenotingAttributeLabel(), SpringContext.getBean(SubFundGroupService.class).getContractsAndGrantsDenotingValueForMessage() });
202 success = false;
203 }
204
205 // KULRNE-4660 - this isn't the child of a CG account; sub account must be ICR type
206 if (!ObjectUtils.isNull(newSubAccount.getA21SubAccount())) {
207 // KFSMI-798 - refresh() changed to refreshNonUpdateableReferences()
208 // All references for A21SubAccount are non-updatable
209 newSubAccount.getA21SubAccount().refreshNonUpdateableReferences();
210 a21SubAccountRefreshed = true;
211 if (StringUtils.isEmpty(newSubAccount.getA21SubAccount().getSubAccountTypeCode()) || !newSubAccount.getA21SubAccount().getSubAccountTypeCode().equals(KFSConstants.SubAccountType.EXPENSE)) {
212 putFieldError("a21SubAccount.subAccountTypeCode", KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_NON_FUNDED_ACCT_SUB_ACCT_TYPE_CODE_INVALID, new String[] { SpringContext.getBean(SubFundGroupService.class).getContractsAndGrantsDenotingAttributeLabel(), SpringContext.getBean(SubFundGroupService.class).getContractsAndGrantsDenotingValueForMessage() });
213 success = false;
214 }
215 }
216
217 return success;
218 }
219 }
220 }
221
222 // short circuit if there is no A21SubAccount object at all (ie, null)
223 if (ObjectUtils.isNull(newSubAccount.getA21SubAccount())) {
224 return success;
225 }
226
227 // FROM HERE ON IN WE CAN ASSUME THERE IS A VALID A21 SUBACCOUNT OBJECT
228
229 // manually refresh the a21SubAccount object, as it wont have been
230 // refreshed by the parent, as its updateable
231 // though only refresh if we didn't refresh in the checks above
232 if (!a21SubAccountRefreshed) {
233 newSubAccount.getA21SubAccount().refresh();
234 }
235
236 // C&G A21 Type field must be in the allowed values
237 if (!KFSConstants.SubAccountType.ELIGIBLE_SUB_ACCOUNT_TYPE_CODES.contains(newSubAccount.getA21SubAccount().getSubAccountTypeCode())) {
238 putFieldError("a21SubAccount.subAccountTypeCode", KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_INVALI_SUBACCOUNT_TYPE_CODES, KFSConstants.SubAccountType.ELIGIBLE_SUB_ACCOUNT_TYPE_CODES.toString());
239 success &= false;
240 }
241
242 // get a convenience reference to this code
243 String cgA21TypeCode = newSubAccount.getA21SubAccount().getSubAccountTypeCode();
244
245 // if this is a Cost Sharing SubAccount, run the Cost Sharing rules
246 if (KFSConstants.SubAccountType.COST_SHARE.trim().equalsIgnoreCase(StringUtils.trim(cgA21TypeCode))) {
247 success &= checkCgCostSharingRules();
248 }
249
250 // if this is an ICR subaccount, run the ICR rules
251 if (KFSConstants.SubAccountType.EXPENSE.trim().equals(StringUtils.trim(cgA21TypeCode))) {
252 success &= checkCgIcrRules();
253 }
254
255 return success;
256 }
257
258 /**
259 * This checks that if the cost share information is filled out that it is valid and exists, or if fields are missing (such as
260 * the chart of accounts code and account number) an error is recorded
261 *
262 * @return true if all cost share fields filled out correctly, false if the chart of accounts code and account number for cost
263 * share are missing
264 */
265 protected boolean checkCgCostSharingRules() {
266
267 boolean success = true;
268 boolean allFieldsSet = false;
269
270 A21SubAccount a21 = newSubAccount.getA21SubAccount();
271
272 // check to see if all required fields are set
273 if (StringUtils.isNotEmpty(a21.getCostShareChartOfAccountCode()) && StringUtils.isNotEmpty(a21.getCostShareSourceAccountNumber())) {
274 allFieldsSet = true;
275 }
276
277 // Cost Sharing COA Code and Cost Sharing Account Number are required
278 success &= checkEmptyBOField("a21SubAccount.costShareChartOfAccountCode", a21.getCostShareChartOfAccountCode(), "Cost Share Chart of Accounts Code");
279 success &= checkEmptyBOField("a21SubAccount.costShareSourceAccountNumber", a21.getCostShareSourceAccountNumber(), "Cost Share AccountNumber");
280
281 // existence test on Cost Share Account
282 if (allFieldsSet) {
283 if (ObjectUtils.isNull(a21.getCostShareAccount())) {
284 putFieldError("a21SubAccount.costShareSourceAccountNumber", KFSKeyConstants.ERROR_EXISTENCE, getDisplayName("a21SubAccount.costShareSourceAccountNumber"));
285 success &= false;
286 }
287 }
288
289 // existence test on Cost Share SubAccount
290 if (allFieldsSet && StringUtils.isNotBlank(a21.getCostShareSourceSubAccountNumber())) {
291 if (ObjectUtils.isNull(a21.getCostShareSourceSubAccount())) {
292 putFieldError("a21SubAccount.costShareSourceSubAccountNumber", KFSKeyConstants.ERROR_EXISTENCE, getDisplayName("a21SubAccount.costShareSourceSubAccountNumber"));
293 success &= false;
294 }
295 }
296
297 // Cost Sharing Account may not be for contracts and grants
298 if (ObjectUtils.isNotNull(a21.getCostShareAccount())) {
299 if (ObjectUtils.isNotNull(a21.getCostShareAccount().getSubFundGroup())) {
300 if (a21.getCostShareAccount().isForContractsAndGrants()) {
301 putFieldError("a21SubAccount.costShareSourceAccountNumber", KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_COST_SHARE_ACCOUNT_MAY_NOT_BE_CG_FUNDGROUP, new String[] { SpringContext.getBean(SubFundGroupService.class).getContractsAndGrantsDenotingAttributeLabel(), SpringContext.getBean(SubFundGroupService.class).getContractsAndGrantsDenotingValueForMessage() });
302 success &= false;
303 }
304 }
305 }
306
307 // The ICR fields must be empty if the sub-account type code is for cost sharing
308 if (checkCgIcrIsEmpty() == false) {
309 putFieldError("a21SubAccount.indirectCostRecoveryTypeCode", KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_ICR_SECTION_INVALID, a21.getSubAccountTypeCode());
310 success &= false;
311 }
312
313 return success;
314 }
315
316 /**
317 * This checks that if the ICR information is entered that it is valid for this fiscal year and that all of its fields are valid
318 * as well (such as account)
319 *
320 * @return true if the ICR information is filled in and it is valid
321 */
322 protected boolean checkCgIcrRules() {
323 A21SubAccount a21 = newSubAccount.getA21SubAccount();
324 if(ObjectUtils.isNull(a21)) {
325 return true;
326 }
327
328 boolean success = true;
329
330 // existence check for ICR Type Code
331 if (StringUtils.isNotEmpty(a21.getIndirectCostRecoveryTypeCode())) {
332 if (ObjectUtils.isNull(a21.getIndirectCostRecoveryType())) {
333 putFieldError("a21SubAccount.indirectCostRecoveryTypeCode", KFSKeyConstants.ERROR_EXISTENCE, "ICR Type Code: " + a21.getIndirectCostRecoveryTypeCode());
334 success = false;
335 }
336 }
337
338 // existence check for Financial Series ID
339 if (StringUtils.isNotEmpty(a21.getFinancialIcrSeriesIdentifier())) {
340 String fiscalYear = StringUtils.EMPTY + SpringContext.getBean(UniversityDateService.class).getCurrentFiscalYear();
341 String icrSeriesId = a21.getFinancialIcrSeriesIdentifier();
342
343 Map<String, String> pkMap = new HashMap<String, String>();
344 pkMap.put(KFSPropertyConstants.UNIVERSITY_FISCAL_YEAR, fiscalYear);
345 pkMap.put(KFSPropertyConstants.FINANCIAL_ICR_SERIES_IDENTIFIER, icrSeriesId);
346 Collection<IndirectCostRecoveryRateDetail> icrRateDetails = getBoService().findMatching(IndirectCostRecoveryRateDetail.class, pkMap);
347
348 if (ObjectUtils.isNull(icrRateDetails) || icrRateDetails.isEmpty()) {
349 String label = SpringContext.getBean(DataDictionaryService.class).getAttributeLabel(A21SubAccount.class, KFSPropertyConstants.FINANCIAL_ICR_SERIES_IDENTIFIER);
350 putFieldError(KFSPropertyConstants.A21_SUB_ACCOUNT + "." + KFSPropertyConstants.FINANCIAL_ICR_SERIES_IDENTIFIER, KFSKeyConstants.ERROR_EXISTENCE, label + " (" + icrSeriesId + ")");
351 success = false;
352 }
353 else {
354 for(IndirectCostRecoveryRateDetail icrRateDetail : icrRateDetails) {
355 if(ObjectUtils.isNull(icrRateDetail.getIndirectCostRecoveryRate())){
356 putFieldError(KFSPropertyConstants.A21_SUB_ACCOUNT + "." + KFSPropertyConstants.FINANCIAL_ICR_SERIES_IDENTIFIER, KFSKeyConstants.IndirectCostRecovery.ERROR_DOCUMENT_ICR_RATE_NOT_FOUND, new String[]{fiscalYear, icrSeriesId});
357 success = false;
358 break;
359 }
360 }
361 }
362 }
363
364 // existence check for ICR Account
365 if (StringUtils.isNotEmpty(a21.getIndirectCostRcvyFinCoaCode()) && StringUtils.isNotEmpty(a21.getIndirectCostRecoveryAcctNbr())) {
366 if (ObjectUtils.isNull(a21.getIndirectCostRecoveryAcct())) {
367 putFieldError("a21SubAccount.indirectCostRecoveryAcctNbr", KFSKeyConstants.ERROR_EXISTENCE, "ICR Account: " + a21.getIndirectCostRcvyFinCoaCode() + "-" + a21.getIndirectCostRecoveryAcctNbr());
368
369 success = false;
370 }
371 }
372
373 // The cost sharing fields must be empty if the sub-account type code is for ICR
374 if (checkCgCostSharingIsEmpty() == false) {
375 putFieldError("a21SubAccount.costShareChartOfAccountCode", KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_COST_SHARE_SECTION_INVALID, a21.getSubAccountTypeCode());
376
377 success &= false;
378 }
379
380 return success;
381 }
382
383 /**
384 * This method tests if all fields in the Cost Sharing section are empty.
385 *
386 * @return true if the cost sharing values passed in are empty, otherwise false.
387 */
388 protected boolean checkCgCostSharingIsEmpty() {
389 boolean success = true;
390
391 A21SubAccount newA21SubAccount = newSubAccount.getA21SubAccount();
392 if (ObjectUtils.isNotNull(newA21SubAccount)) {
393 success &= StringUtils.isEmpty(newA21SubAccount.getCostShareChartOfAccountCode());
394 success &= StringUtils.isEmpty(newA21SubAccount.getCostShareSourceAccountNumber());
395 success &= StringUtils.isEmpty(newA21SubAccount.getCostShareSourceSubAccountNumber());
396 }
397
398 return success;
399 }
400
401 /**
402 * This method tests if all fields in the ICR section are empty.
403 *
404 * @return true if the ICR values passed in are empty, otherwise false.
405 */
406 protected boolean checkCgIcrIsEmpty() {
407 boolean success = true;
408
409 A21SubAccount newA21SubAccount = newSubAccount.getA21SubAccount();
410 if (ObjectUtils.isNotNull(newA21SubAccount)) {
411 success &= StringUtils.isEmpty(newA21SubAccount.getFinancialIcrSeriesIdentifier());
412 success &= StringUtils.isEmpty(newA21SubAccount.getIndirectCostRcvyFinCoaCode());
413 success &= StringUtils.isEmpty(newA21SubAccount.getIndirectCostRecoveryAcctNbr());
414 success &= StringUtils.isEmpty(newA21SubAccount.getIndirectCostRecoveryTypeCode());
415 // this is a boolean, so create any value if set to true, meaning a user checked the box, otherwise assume it's empty
416 success &= StringUtils.isEmpty(newA21SubAccount.getOffCampusCode() ? "1" : "");
417 }
418
419 return success;
420 }
421
422 /**
423 * This method tests the value entered, and if there is anything there it logs a new error, and returns false.
424 *
425 * @param value - String value to be tested
426 * @param fieldName - name of the field being tested
427 * @return false if there is any value in value, otherwise true
428 */
429 protected boolean disallowAnyValues(String value, String fieldName) {
430 if (StringUtils.isNotEmpty(value)) {
431 putFieldError(fieldName, KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_NOT_AUTHORIZED_ENTER_CG_FIELDS, getDisplayName(fieldName));
432 return false;
433 }
434 return true;
435 }
436
437 /**
438 * This method tests the two values entered, and if there is any change between the two, it logs an error, and returns false.
439 * Note that the comparison is done after trimming both leading and trailing whitespace from both strings, and then doing a
440 * case-insensitive comparison.
441 *
442 * @param oldValue - the original String value of the field
443 * @param newValue - the new String value of the field
444 * @param fieldName - name of the field being tested
445 * @return false if there is any difference between the old and new, true otherwise
446 */
447 protected boolean disallowChangedValues(String oldValue, String newValue, String fieldName) {
448
449 if (isFieldValueChanged(oldValue, newValue)) {
450 putFieldError(fieldName, KFSKeyConstants.ERROR_DOCUMENT_SUBACCTMAINT_NOT_AUTHORIZED_CHANGE_CG_FIELDS, getDisplayName(fieldName));
451 return false;
452 }
453 return true;
454 }
455
456 /**
457 * This compares two string values to see if the newValue has changed from the oldValue
458 *
459 * @param oldValue - original value
460 * @param newValue - new value
461 * @return true if the two fields are different from each other
462 */
463 protected boolean isFieldValueChanged(String oldValue, String newValue) {
464
465 if (StringUtils.isBlank(oldValue) && StringUtils.isBlank(newValue)) {
466 return false;
467 }
468
469 if (StringUtils.isBlank(oldValue) && StringUtils.isNotBlank(newValue)) {
470 return true;
471 }
472
473 if (StringUtils.isNotBlank(oldValue) && StringUtils.isBlank(newValue)) {
474 return true;
475 }
476
477 if (!oldValue.trim().equalsIgnoreCase(newValue.trim())) {
478 return true;
479 }
480
481 return false;
482 }
483
484
485 /**
486 * This method retrieves the label name for a specific property
487 *
488 * @param propertyName - property to retrieve label for (from the DD)
489 * @return the label
490 */
491 protected String getDisplayName(String propertyName) {
492 return getDdService().getAttributeLabel(SubAccount.class, propertyName);
493 }
494
495 }