/* jBilling - The Enterprise Open Source Billing System Copyright (C) 2003-2011 Enterprise jBilling Software Ltd. and Emiliano Conde This file is part of jbilling. jbilling 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. jbilling 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 jbilling. If not, see <http://www.gnu.org/licenses/>. */ package com.sapienter.jbilling.server.mediation; import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import javax.persistence.EntityNotFoundException; import com.sapienter.jbilling.server.mediation.db.MediationRecordStatusDAS; import com.sapienter.jbilling.server.mediation.db.MediationRecordStatusDTO; import com.sapienter.jbilling.server.mediation.task.IMediationErrorHandler; import com.sapienter.jbilling.server.pluggableTask.admin.PluggableTaskException; import org.apache.log4j.Logger; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import com.sapienter.jbilling.common.InvalidArgumentException; import com.sapienter.jbilling.common.SessionInternalError; import com.sapienter.jbilling.server.mediation.db.MediationConfiguration; import com.sapienter.jbilling.server.mediation.db.MediationConfigurationDAS; import com.sapienter.jbilling.server.mediation.db.MediationMapDAS; import com.sapienter.jbilling.server.mediation.db.MediationOrderMap; import com.sapienter.jbilling.server.mediation.db.MediationProcess; import com.sapienter.jbilling.server.mediation.db.MediationProcessDAS; import com.sapienter.jbilling.server.mediation.db.MediationRecordDAS; import com.sapienter.jbilling.server.mediation.db.MediationRecordDTO; import com.sapienter.jbilling.server.mediation.db.MediationRecordLineDAS; import com.sapienter.jbilling.server.mediation.db.MediationRecordLineDTO; import com.sapienter.jbilling.server.mediation.task.IMediationProcess; import com.sapienter.jbilling.server.mediation.task.IMediationReader; import com.sapienter.jbilling.server.mediation.task.MediationResult; import com.sapienter.jbilling.server.order.db.OrderLineDAS; import com.sapienter.jbilling.server.order.db.OrderLineDTO; import com.sapienter.jbilling.server.pluggableTask.TaskException; import com.sapienter.jbilling.server.pluggableTask.admin.PluggableTaskBL; import com.sapienter.jbilling.server.pluggableTask.admin.PluggableTaskDAS; import com.sapienter.jbilling.server.pluggableTask.admin.PluggableTaskDTO; import com.sapienter.jbilling.server.pluggableTask.admin.PluggableTaskManager; import com.sapienter.jbilling.server.user.EntityBL; import com.sapienter.jbilling.server.util.Constants; import com.sapienter.jbilling.server.util.Context; import com.sapienter.jbilling.server.util.audit.EventLogger; import java.util.ArrayList; import java.util.Map; import org.springframework.util.StopWatch; /** * * @author emilc **/ @Transactional( propagation = Propagation.REQUIRED ) public class MediationSessionBean implements IMediationSessionBean { private static final Logger LOG = Logger.getLogger(MediationSessionBean.class); private static StopWatch stopWatch = null; /** * Trigger the mediation process. Only one mediation process can be running at any given * time, this method will not start an additional mediation process if one is already running. */ public void trigger(Integer entityId) { LOG.debug("Running mediation trigger for entity " + entityId); StopWatch watch = new StopWatch("trigger watch"); watch.start(); // local instance of this bean to invoke transactional methods IMediationSessionBean local = (IMediationSessionBean) Context.getBean(Context.Name.MEDIATION_SESSION); /* There can only be one process running for this entity, check that there is no other mediation process running for this entity before continuing. */ if (local.isProcessing(entityId)) { LOG.debug("Entity " + entityId + " already has a running mediation process, skipping run"); return; } // fetch mediation processing plug in (usually a rules based processor) IMediationProcess processTask; try { PluggableTaskManager<IMediationProcess> taskManager = new PluggableTaskManager<IMediationProcess>(entityId, Constants.PLUGGABLE_TASK_MEDIATION_PROCESS); processTask = taskManager.getNextClass(); } catch (PluggableTaskException e) { throw new SessionInternalError("Could not retrieve mediation process plug-in.", e); } if (processTask == null) { LOG.debug("Entity " + entityId + " does not have a mediation process plug-in"); return; } // find the root user of this entity, will be used as the executor for order updates Integer executorId = new EntityBL().getRootUser(entityId); List<String> errorMessages = new ArrayList<String>(); // process each mediation configuration for this entity for (MediationConfiguration cfg : local.getAllConfigurations(entityId)) { /* Double check that we're still the only mediation process running before we create a new MediationProcess record. The mediation processing plug-in may have a long instantiation time which leaves a window for another overlapping process to be created. */ if (local.isProcessing(entityId)) { LOG.debug("Entity " + entityId + " already has an existing mediation process, skipping run"); return; } // create process record and mark start time LOG.debug("Now using configuration " + cfg); MediationProcess process = local.createProcessRecord(cfg); // fetch mediation reader plug-in IMediationReader reader; try { PluggableTaskBL<IMediationReader> readerTask = new PluggableTaskBL<IMediationReader>(); readerTask.set(cfg.getPluggableTask()); reader = readerTask.instantiateTask(); } catch (PluggableTaskException e) { throw new SessionInternalError("Could not instantiate mediation reader plug-in.", e); } // read records and normalize using the MediationProcess plug-in if (reader.validate(errorMessages)) { /* Catch exceptions and log errors instead of re-throwing as SessionInternalError so that the remaining mediation configurations can be run, and so that this process can be "completed" by setting the end date. */ try { stopWatch = new StopWatch(); stopWatch.start("Reading records"); for (List<Record> thisGroup : reader) { stopWatch.stop(); LOG.debug("Now processing " + thisGroup.size() + " records."); local.normalizeRecordGroup(processTask, executorId, process, thisGroup, entityId, cfg); LOG.debug(stopWatch.prettyPrint()); stopWatch = new StopWatch(); stopWatch.start("Reading records"); } } catch (TaskException e) { LOG.error("Exception occurred processing mediation records.", e); } catch (Throwable t) { LOG.error("Unhandled exception occurred during mediation.", t); } } // mark process end date local.updateProcessRecord(process, new Date()); LOG.debug("Configuration '" + cfg.getName() + "' finished at " + process.getEndDatetime()); } // throw a SessionInternalError of errors were returned from the reader plug-in if (!errorMessages.isEmpty()) { StringBuffer buffer = new StringBuffer("Invalid reader plug-in configuration \n"); for (String message : errorMessages) { buffer.append("ERROR: ") .append(message) .append("\n"); } throw new SessionInternalError(buffer.toString()); } watch.stop(); LOG.debug("Mediation process done. Duration (ms):" + watch.getTotalTimeMillis()); } /** * Create a new MediationProcess for the given configuration, marking the * start time of the process and initializing the affected order count. * * @param cfg mediation configuration * @return new MediationProcess record */ @Transactional( propagation = Propagation.REQUIRES_NEW ) public MediationProcess createProcessRecord(MediationConfiguration cfg) { MediationProcessDAS processDAS = new MediationProcessDAS(); MediationProcess process = new MediationProcess(); process.setConfiguration(cfg); process.setStartDatetime(Calendar.getInstance().getTime()); process.setOrdersAffected(0); process = processDAS.save(process); processDAS.flush(); return process; } /** * Updated the end time of the given MediationProcess, effectively marking * the process as completed. * * @param process MediationProcess to update * @param enddate end time to set * @return updated MediationProcess record */ @Transactional( propagation = Propagation.REQUIRES_NEW ) public MediationProcess updateProcessRecord(MediationProcess process, Date enddate) { new MediationProcessDAS().reattach(process); process.setEndDatetime(enddate); return process; } /** * Returns true if a running MediationProcess exists for the given entity id. A * process is considered to be running if it does not have an end time. * * @param entityId entity id to check * @return true if a process is running for the given entity, false if not */ @Transactional( propagation = Propagation.REQUIRES_NEW ) public boolean isProcessing(Integer entityId) { return new MediationProcessDAS().isProcessing(entityId); } public List<MediationProcess> getAll(Integer entityId) { MediationProcessDAS processDAS = new MediationProcessDAS(); List<MediationProcess> result = processDAS.findAllByEntity(entityId); processDAS.touch(result); return result; } /** * Returns a list of all MediationConfiguration's for the given entity id. * * @param entityId entity id * @return list of mediation configurations for entity, empty list if none found */ public List<MediationConfiguration> getAllConfigurations(Integer entityId) { MediationConfigurationDAS cfgDAS = new MediationConfigurationDAS(); return cfgDAS.findAllByEntity(entityId); } public void createConfiguration(MediationConfiguration cfg) { MediationConfigurationDAS cfgDAS = new MediationConfigurationDAS(); cfg.setCreateDatetime(Calendar.getInstance().getTime()); cfgDAS.save(cfg); } public List<MediationConfiguration> updateAllConfiguration(Integer executorId, List<MediationConfiguration> configurations) throws InvalidArgumentException { MediationConfigurationDAS cfgDAS = new MediationConfigurationDAS(); List<MediationConfiguration> retValue = new ArrayList<MediationConfiguration>(); try { for (MediationConfiguration cfg : configurations) { // if the configuration is new, the task needs to be loaded if (cfg.getPluggableTask().getEntityId() == null) { PluggableTaskDAS pt = (PluggableTaskDAS) Context.getBean(Context.Name.PLUGGABLE_TASK_DAS); PluggableTaskDTO task = pt.find(cfg.getPluggableTask().getId()); if (task != null && task.getEntityId().equals(cfg.getEntityId())) { cfg.setPluggableTask(task); } else { throw new InvalidArgumentException("Task not found or " + "entity of pluggable task is not the same when " + "creating a new mediation configuration", 1); } } retValue.add(cfgDAS.save(cfg)); } return retValue; } catch (EntityNotFoundException e1) { throw new InvalidArgumentException("Wrong data saving mediation configuration", 1, e1); } catch (InvalidArgumentException e2) { throw new InvalidArgumentException(e2); } catch (Exception e) { throw new SessionInternalError("Exception updating mediation configurations ", MediationSessionBean.class, e); } } public void delete(Integer executorId, Integer cfgId) { MediationConfigurationDAS cfgDAS = new MediationConfigurationDAS(); cfgDAS.delete(cfgDAS.find(cfgId)); EventLogger.getInstance().audit(executorId, null, Constants.TABLE_MEDIATION_CFG, cfgId, EventLogger.MODULE_MEDIATION, EventLogger.ROW_DELETED, null, null, null); } /** * Calculation number of records for each of the existing mediation record statuses * * @param entityId EntityId for searching mediationRecords * @return map of mediation status as a key and long value as a number of records whit given status */ public Map<MediationRecordStatusDTO, Long> getNumberOfRecordsByStatuses(Integer entityId) { MediationRecordDAS recordDas = new MediationRecordDAS(); MediationRecordStatusDAS recordStatusDas = new MediationRecordStatusDAS(); Map<MediationRecordStatusDTO, Long> resultMap = new HashMap<MediationRecordStatusDTO, Long>(); List<MediationRecordStatusDTO> statuses = recordStatusDas.findAll(); //propagate proxy objects for using out of the transaction recordStatusDas.touch(statuses); for (MediationRecordStatusDTO status : statuses) { Long recordsCount = recordDas.countMediationRecordsByEntityIdAndStatus(entityId, status); resultMap.put(status, recordsCount); } return resultMap; } public boolean hasBeenProcessed(MediationProcess process, Record record) { MediationRecordDAS recordDas = new MediationRecordDAS(); // validate that this group has not been already processed if (recordDas.processed(record.getKey())) { LOG.debug("Detected duplicated of record: " + record.getKey()); return true; } LOG.debug("Detected record as a new event: " + record.getKey()); // assign to record DONE_AND_BILLABLE status as default before processing // after actual processing it will be updated MediationRecordStatusDTO status = new MediationRecordStatusDAS().find(Constants.MEDIATION_RECORD_STATUS_DONE_AND_BILLABLE); MediationRecordDTO dbRecord = new MediationRecordDTO(record.getKey(), Calendar.getInstance().getTime(), process, status); recordDas.save(dbRecord); recordDas.flush(); return false; } @Transactional(propagation = Propagation.REQUIRES_NEW) public void normalizeRecordGroup(IMediationProcess processTask, Integer executorId, MediationProcess process, List<Record> thisGroup, Integer entityId, MediationConfiguration cfg) throws TaskException { stopWatch.start("Pre-processing"); LOG.debug("Normalizing " + thisGroup.size() + " records ..."); // this process came from a different transaction (persistent context) new MediationProcessDAS().reattachUnmodified(process); // validate that these records have not been already processed for (Iterator<Record> it = thisGroup.iterator(); it.hasNext();) { if (hasBeenProcessed(process, it.next())) it.remove(); } if (thisGroup.size() == 0) { return; // it could be that they all have been processed already } ArrayList<MediationResult> results = new ArrayList<MediationResult>(0); // call the plug-in to resolve these records stopWatch.stop(); stopWatch.start("Processing"); processTask.process(thisGroup, results, cfg.getName()); stopWatch.stop(); stopWatch.start("Post-Processing"); LOG.debug("Processing " + thisGroup.size() + " records took: " + stopWatch.getLastTaskTimeMillis() + "ms," + " or " + new Double(thisGroup.size()) / stopWatch.getLastTaskTimeMillis() * 1000D + " records/sec"); // go over the results for (MediationResult result : results) { if (!result.isDone()) { // this is an error, the rules failed somewhere because the // 'done' flag is still false. LOG.debug("Record result is not done"); // errors presented, status of record should be updated assignStatusToMediationRecord(result.getRecordKey(), new MediationRecordStatusDAS().find(Constants.MEDIATION_RECORD_STATUS_ERROR_DETECTED)); // call error handler for mediation errors handleMediationErrors(findRecordByKey(thisGroup, result.getRecordKey()), resolveMediationResultErrors(result), entityId, cfg); } else if (!result.getErrors().isEmpty()) { // There are some user-detected errors LOG.debug("Record result is done with errors"); //done, but errors assigned by rules. status of record should be updated assignStatusToMediationRecord(result.getRecordKey(), new MediationRecordStatusDAS().find(Constants.MEDIATION_RECORD_STATUS_ERROR_DECLARED)); // call error handler for rules errors handleMediationErrors(findRecordByKey(thisGroup, result.getRecordKey()), result.getErrors(), entityId, cfg); } else { // this record was process without any errors LOG.debug("Record result is done"); if (result.getLines() == null || result.getLines().isEmpty()) { //record was processed, but order lines was not affected //now record has status DONE_AND_BILLABLE, it should be changed assignStatusToMediationRecord(result.getRecordKey(), new MediationRecordStatusDAS().find(Constants.MEDIATION_RECORD_STATUS_DONE_AND_NOT_BILLABLE)); //not needed to update order affected or lines in this case } else { //record has status DONE_AND_BILLABLE, only needed to save processed lines process.setOrdersAffected(process.getOrdersAffected() + result.getLines().size()); // relate this order with this process MediationOrderMap map = new MediationOrderMap(); map.setMediationProcessId(process.getId()); map.setOrderId(result.getCurrentOrder().getId()); MediationMapDAS mapDas = new MediationMapDAS(); mapDas.save(map); // add the record lines // todo: could be problematic if asynchronous mediation processes are running. // a better approach is to link MediationResult to the record by the unique ID -- future enhancement saveEventRecordLines(result.getDiffLines(), new MediationRecordDAS().findNewestByKey(result.getRecordKey()), result.getEventDate(), result.getDescription()); } } } stopWatch.stop(); } public void saveEventRecordLines(List<OrderLineDTO> newLines, MediationRecordDTO record, Date eventDate, String description) { MediationRecordLineDAS mediationRecordLineDas = new MediationRecordLineDAS(); for (OrderLineDTO line : newLines) { MediationRecordLineDTO recordLine = new MediationRecordLineDTO(); recordLine.setEventDate(eventDate); OrderLineDTO dbLine = new OrderLineDAS().find(line.getId()); recordLine.setOrderLine(dbLine); recordLine.setAmount(line.getAmount()); recordLine.setQuantity(line.getQuantity()); recordLine.setRecord(record); recordLine.setDescription(description); recordLine = mediationRecordLineDas.save(recordLine); // no need to link to the parent record. The association is completed already // record.getLines().add(recordLine); } } public List<MediationRecordLineDTO> getEventsForOrder(Integer orderId) { List<MediationRecordLineDTO> events = new MediationRecordLineDAS().getByOrder(orderId); for (MediationRecordLineDTO line : events) { line.toString(); //as a touch } return events; } public List<MediationRecordDTO> getMediationRecordsByMediationProcess(Integer mediationProcessId) { return new MediationRecordDAS().findByProcess(mediationProcessId); } private void assignStatusToMediationRecord(String key, MediationRecordStatusDTO status) { MediationRecordDAS recordDas = new MediationRecordDAS(); MediationRecordDTO recordDto = recordDas.findNewestByKey(key); if (recordDto != null) { recordDto.setRecordStatus(status); recordDas.save(recordDto); } else { LOG.debug("Mediation record with key=" + key + " not found"); } } private List<String> resolveMediationResultErrors(MediationResult result) { List<String> errors = new LinkedList<String>(); if (result.getLines() == null || result.getLines().isEmpty()) { errors.add("JB-NO_LINE"); } if (result.getDiffLines() == null || result.getDiffLines().isEmpty()) { errors.add("JB-NO_DIFF"); } if (result.getCurrentOrder() == null) { errors.add("JB-NO_ORDER"); } if (result.getUserId() == null) { errors.add("JB-NO_USER"); } if (result.getCurrencyId() == null) { errors.add("JB-NO_CURRENCY"); } if (result.getEventDate() == null) { errors.add("JB-NO_DATE"); } errors.addAll(result.getErrors()); return errors; } private Record findRecordByKey(List<Record> records, String key) { for (Record r : records) { if (r.getKey().equals(key)) { return r; } } return null; } private void handleMediationErrors(Record record, List<String> errors, Integer entityId, MediationConfiguration cfg) { if (record == null) return; StopWatch watch = new StopWatch("saving errors watch"); watch.start(); LOG.debug("Saving mediation result errors: " + errors.size()); try { PluggableTaskManager<IMediationErrorHandler> tm = new PluggableTaskManager<IMediationErrorHandler>(entityId, Constants.PLUGGABLE_TASK_MEDIATION_ERROR_HANDLER); IMediationErrorHandler errorHandler; // iterate through all error handlers for current entityId // and process errors while ((errorHandler = tm.getNextClass()) != null) { try { errorHandler.process(record, errors, new Date(), cfg); } catch (TaskException e) { // exception catched for opportunity of processing errors by other handlers // and continue mediation process for other records // TO-DO: check requirements about error handling in that case LOG.error(e); } } } catch (PluggableTaskException e) { LOG.error(e); // it's possible plugin configuration exception // TO-DO: check requirements about error handling // may be rethrow exception } watch.stop(); LOG.debug("Saving mediation result errors done. Duration (mls):" + watch.getTotalTimeMillis()); } }