/******************************************************************************
 * Product: Adempiere ERP & CRM Smart Business Solution                       *
 * Copyright (C) 1999-2006 ComPiere, Inc. All Rights Reserved.                *
 * This program is free software; you can redistribute it and/or modify it    *
 * under the terms version 2 of the GNU General Public License as published   *
 * by the Free Software Foundation. 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 General Public License for more details.                       *
 * You should have received a copy of the GNU General Public License along    *
 * with this program; if not, write to the Free Software Foundation, Inc.,    *
 * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA.                     *
 * For the text or an alternative of this public license, you may reach us    *
 * ComPiere, Inc., 2620 Augustine Dr. #245, Santa Clara, CA 95054, USA        *
 * or via info@compiere.org or http://www.compiere.org/license.html           *
 *****************************************************************************/
package org.compiere.wf;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.logging.Level;
import java.util.stream.Collectors;
import org.adempiere.exceptions.DBException;
import org.compiere.model.MColumn;
import org.compiere.model.MMenu;
import org.compiere.model.MProduct;
import org.compiere.model.MTable;
import org.compiere.model.PO;
import org.compiere.model.Query;
import org.compiere.model.X_AD_Workflow;
import org.compiere.process.DocAction;
import org.compiere.process.ProcessInfo;
import org.compiere.process.ServerProcessCtl;
import org.compiere.process.StateEngine;
import org.compiere.util.CCache;
import org.compiere.util.CLogger;
import org.compiere.util.DB;
import org.compiere.util.Env;
import org.compiere.util.Msg;
import org.compiere.util.Trx;
import org.compiere.util.Util;
import org.idempiere.cache.ImmutablePOSupport;
import org.idempiere.cache.ImmutablePOCache;
/**
 *	Extended WorkFlow Model for AD_Workflow
 *
 * 	@author 	Jorg Janke
 * 	@version 	$Id: MWorkflow.java,v 1.4 2006/07/30 00:51:05 jjanke Exp $
 * 
 *  @author Teo Sarca, www.arhipac.ro
 * 			
FR [ 2214883 ] Remove SQL code and Replace for Query
 * 			BF [ 2665963 ] Copy Workflow name in Activity name
 *  @author Silvano Trinchero, www.freepath.it
 * 			IDEMPIERE-3209 changed functions to public to improve integration support
 */
public class MWorkflow extends X_AD_Workflow implements ImmutablePOSupport
{
	/**
	 * generated serial id 
	 */
	private static final long serialVersionUID = 727250581144217545L;
	/**
	 * 	Get Workflow from Cache (immutable)
	 *	@param AD_Workflow_ID id
	 *	@return workflow
	 */
	public static MWorkflow get (int AD_Workflow_ID)
	{
		return get(Env.getCtx(), AD_Workflow_ID);
	}
	
	/**
	 * 	Get Workflow from Cache (immutable)
	 *	@param ctx context
	 *	@param AD_Workflow_ID id
	 *	@return workflow
	 */
	public static MWorkflow get (Properties ctx, int AD_Workflow_ID)
	{
		String key = Env.getAD_Language(ctx) + "_" + Env.getAD_Client_ID(ctx) + "_" + AD_Workflow_ID;
		MWorkflow retValue = s_cache.get(ctx, key, e -> new MWorkflow(ctx, e));
		if (retValue != null)
			return retValue;
		retValue = new MWorkflow (ctx, AD_Workflow_ID, (String)null);
		if (retValue.get_ID() == AD_Workflow_ID) 
		{
			s_cache.put(key, retValue, e -> new MWorkflow(Env.getCtx(), e));
			return retValue;
		}
		return null;
	}	//	get
	
	/**
	 * Get updateable copy of MWorkflow from cache
	 * @param ctx
	 * @param AD_Workflow_ID
	 * @param trxName
	 * @return MWorkflow 
	 */
	public static MWorkflow getCopy(Properties ctx, int AD_Workflow_ID, String trxName)
	{
		MWorkflow wf = get(AD_Workflow_ID);
		if (wf != null)
			wf = new MWorkflow(ctx, wf, trxName);
		return wf;
	}
	
	/**
	 * 	Get Doc Value Workflows
	 *	@param ctx context
	 *	@param AD_Client_ID client
	 *	@param AD_Table_ID table
	 *  @param trxName
	 *	@return document value workflow array or null
	 */
	public static synchronized MWorkflow[] getDocValue (Properties ctx, int AD_Client_ID, int AD_Table_ID
			, String trxName)
	{
		//	Reload
		Map cachedMap = s_cacheDocValue.get(AD_Client_ID);
		if (cachedMap == null)
		{
			final String whereClause = "WorkflowType=? AND IsValid=? AND AD_Client_ID=?";
			List workflows;
			workflows = new Query(ctx, Table_Name, whereClause, trxName)
					.setParameters(new Object[]{WORKFLOWTYPE_DocumentValue, true, Env.getAD_Client_ID(ctx)})
					.setOnlyActiveRecords(true)
					.setOrderBy("AD_Table_ID")
					.list();
			cachedMap = new HashMap();
			s_cacheDocValue.put(AD_Client_ID, cachedMap);
			ArrayList list = new ArrayList();
			int previousTableId = -1;
			int currentTableId = -1;
			for (MWorkflow wf : workflows)
			{
				currentTableId = wf.getAD_Table_ID();
				if (currentTableId !=  previousTableId && list.size() > 0)
				{
					cachedMap.put (previousTableId, list.stream().map(e -> {return new MWorkflow(Env.getCtx(), e);}).toArray(MWorkflow[]::new));
					list = new ArrayList();
				}
				previousTableId = currentTableId;
				list.add(wf);
			}
			
			//	Last one
			if (list.size() > 0)
			{
				cachedMap.put (previousTableId, list.stream().map(e -> {return new MWorkflow(Env.getCtx(), e);}).toArray(MWorkflow[]::new));
			}
			if (s_log.isLoggable(Level.CONFIG)) s_log.config("#" + cachedMap.size());
		}
		//	Look for Entry
		MWorkflow[] retValue = (MWorkflow[])cachedMap.get(AD_Table_ID);
		return retValue != null ? Arrays.stream(retValue).map(e -> {return new MWorkflow(ctx, e, trxName);}).toArray(MWorkflow[]::new) : null;
	}	//	getDocValue
	
	/**	Single Cache					*/
	private static ImmutablePOCache	s_cache = new ImmutablePOCache(Table_Name, Table_Name, 20);
	/**	Document Value Cache			*/
	private static final CCache> s_cacheDocValue = new CCache<> (Table_Name, Table_Name+"|DocumentValue", 5) {
		/**
		 * generated serial id
		 */
		private static final long serialVersionUID = 2548097748351277269L;
		@Override
		public int reset(int recordId) {
			return reset();
		}		
	};
	/**	Static Logger	*/
	private static CLogger	s_log	= CLogger.getCLogger (MWorkflow.class);
		
    /**
     * UUID based Constructor
     * @param ctx  Context
     * @param AD_Workflow_UU  UUID key
     * @param trxName Transaction
     */
    public MWorkflow(Properties ctx, String AD_Workflow_UU, String trxName) {
        super(ctx, AD_Workflow_UU, trxName);
		if (Util.isEmpty(AD_Workflow_UU))
			setInitialDefaults();
		loadTrl();
		loadNodes();
    }
	/**
	 * 	Create/Load Workflow
	 * 	@param ctx Context
	 * 	@param AD_Workflow_ID ID
	 * 	@param trxName transaction
	 */
	public MWorkflow (Properties ctx, int AD_Workflow_ID, String trxName)
	{
		super (ctx, AD_Workflow_ID, trxName);
		if (AD_Workflow_ID == 0)
			setInitialDefaults();
		loadTrl();
		loadNodes();
	}	//	MWorkflow
	
	/**
	 * Set the initial defaults for a new record
	 */
	private void setInitialDefaults() {
		//	setAD_Workflow_ID (0);
		//	setValue (null);
		//	setName (null);
		setAccessLevel (ACCESSLEVEL_Organization);
		setAuthor ("ComPiere, Inc.");
		setDurationUnit(DURATIONUNIT_Day);
		setDuration (1);
		setEntityType (ENTITYTYPE_UserMaintained);	// U
		setIsDefault (false);
		setPublishStatus (PUBLISHSTATUS_UnderRevision);	// U
		setVersion (0);
		setCost (Env.ZERO);
		setWaitingTime (0);
		setWorkingTime (0);
		setIsBetaFunctionality(false);
	}
	/**
	 * 	Load Constructor
	 * 	@param ctx context
	 * 	@param rs result set
	 * 	@param trxName transaction
	 */
	public MWorkflow (Properties ctx, ResultSet rs, String trxName)
	{
		super(ctx, rs, trxName);
		loadTrl();
		loadNodes();
	}	//	Workflow
	/**
	 * Copy constructor
	 * @param copy
	 */
	public MWorkflow(MWorkflow copy) 
	{
		this(Env.getCtx(), copy);
	}
	/**
	 * Copy constructor
	 * @param ctx
	 * @param copy
	 */
	public MWorkflow(Properties ctx, MWorkflow copy) 
	{
		this(ctx, copy, (String) null);
	}
	/**
	 * Copy constructor
	 * @param ctx
	 * @param copy
	 * @param trxName
	 */
	public MWorkflow(Properties ctx, MWorkflow copy, String trxName) 
	{
		this(ctx, 0, trxName);
		copyPO(copy);
		this.m_description_trl = copy.m_description_trl;
		this.m_help_trl = copy.m_help_trl;
		this.m_name_trl = copy.m_name_trl;
		this.m_nodes = copy.m_nodes != null ? copy.m_nodes.stream().map(e ->{return new MWFNode(ctx, e, trxName);}).collect(Collectors.toCollection(ArrayList::new)) : null;
		this.m_translated = copy.m_translated;
	}
	
	/**	WF Nodes				*/
	private List	m_nodes = new ArrayList();
	/**	Translated Name			*/
	private String			m_name_trl = null;
	/**	Translated Description	*/
	private String			m_description_trl = null;
	/**	Translated Help			*/
	private String			m_help_trl = null;
	/**	Translation Flag		*/
	private boolean			m_translated = false;
	/**
	 * 	Load Translation
	 */
	private void loadTrl()
	{
		if (Env.isBaseLanguage(getCtx(), "AD_Workflow") || get_ID() == 0)
			return;
		String sql = "SELECT Name, Description, Help FROM AD_Workflow_Trl WHERE AD_Workflow_ID=? AND AD_Language=?";
		PreparedStatement pstmt = null;
		ResultSet rs = null;
		try
		{
			pstmt = DB.prepareStatement(sql, null);
			pstmt.setInt(1, get_ID());
			pstmt.setString(2, Env.getAD_Language(getCtx()));
			rs = pstmt.executeQuery();
			if (rs.next())
			{
				m_name_trl = rs.getString(1);
				m_description_trl = rs.getString(2);
				m_help_trl = rs.getString(3);
				m_translated = true;
			}
		}
		catch (SQLException e)
		{
			//log.log(Level.SEVERE, sql, e);
			throw new DBException(e, sql);
		}
		finally
		{
			DB.close(rs, pstmt);
			rs = null; pstmt = null;
		}
		if (log.isLoggable(Level.FINE)) log.fine("Translated=" + m_translated);
	}	//	loadTrl
	/**
	 * 	Load All Nodes
	 */
	private void loadNodes()
	{
		m_nodes = new Query(getCtx(), MWFNode.Table_Name, "AD_WorkFlow_ID=? AND AD_Client_ID IN (0, ?)", get_TrxName())
			.setParameters(get_ID(), Env.getAD_Client_ID(Env.getCtx()))
			.setOnlyActiveRecords(true)
			.list();
		if (m_nodes.size() > 0 && is_Immutable())
			m_nodes.stream().forEach(e -> e.markImmutable());
		if (log.isLoggable(Level.FINE)) log.fine("#" + m_nodes.size());
	}	//	loadNodes
	
	/**
	 * 	Get Number of Nodes
	 * 	@return number of nodes
	 */
	public int getNodeCount()
	{
		return m_nodes.size();
	}	//	getNextNodeCount
	/**
	 * 	Get the nodes
	 *  @param ordered ordered array
	 * 	@param AD_Client_ID for client
	 * 	@return array of nodes
	 */
	public MWFNode[] getNodes(boolean ordered, int AD_Client_ID)
	{
		if (ordered)
			return getNodesInOrder(AD_Client_ID);
		//
		ArrayList list = new ArrayList();
		for (int i = 0; i < m_nodes.size(); i++)
		{
			MWFNode node = m_nodes.get(i);
			if (!node.isActive())
				continue;
			if (node.getAD_Client_ID() == 0 || node.getAD_Client_ID() == AD_Client_ID)
				list.add(node);
		}
		MWFNode[] retValue = new MWFNode [list.size()];
		list.toArray(retValue);
		return retValue;
	}	//	getNodes
	/**
	 * Reload all nodes
	 */
	public void reloadNodes() {
		m_nodes = null;
		loadNodes();
	}
	/**
	 * 	Get the first node
	 * 	@return first workflow node
	 */
	public MWFNode getFirstNode()
	{
		return getNode (getAD_WF_Node_ID());
	}	//	getFirstNode
	/**
	 * 	Get Node with ID in Workflow
	 * 	@param AD_WF_Node_ID ID
	 * 	@return node or null
	 */
	protected MWFNode getNode (int AD_WF_Node_ID)
	{
		for (int i = 0; i < m_nodes.size(); i++)
		{
			MWFNode node = (MWFNode)m_nodes.get(i);
			if (node.getAD_WF_Node_ID() == AD_WF_Node_ID)
				return node;
		}
		return null;
	}	//	getNode
	/**
	 * 	Get the next nodes
	 * 	@param AD_WF_Node_ID ID
	 * 	@param AD_Client_ID for client
	 * 	@return array of next nodes or null
	 */
	public MWFNode[] getNextNodes (int AD_WF_Node_ID, int AD_Client_ID)
	{
		MWFNode node = getNode(AD_WF_Node_ID);
		if (node == null || node.getNextNodeCount() == 0)
			return null;
		//
		MWFNodeNext[] nexts = node.getTransitions(AD_Client_ID);
		ArrayList list = new ArrayList();
		for (int i = 0; i < nexts.length; i++)
		{
			MWFNode next = getNode (nexts[i].getAD_WF_Next_ID());
			if (next != null)
				list.add(next);
		}
		//	Return Nodes
		MWFNode[] retValue = new MWFNode [list.size()];
		list.toArray(retValue);
		return retValue;
	}	//	getNextNodes
	/**
	 * 	Get The Nodes in Sequence Order
	 * 	@param AD_Client_ID client
	 * 	@return Nodes in sequence
	 */
	public MWFNode[] getNodesInOrder(int AD_Client_ID)
	{
		ArrayList list = new ArrayList();
		addNodesSF (list, getAD_WF_Node_ID(), AD_Client_ID);	//	start with first
		//	Remaining Nodes
		if (m_nodes.size() != list.size())
		{
			//	Add Stand alone
			for (int n = 0; n < m_nodes.size(); n++)
			{
				MWFNode node = (MWFNode)m_nodes.get(n);
				if (!node.isActive())
					continue;
				if (node.getAD_Client_ID() == 0 || node.getAD_Client_ID() == AD_Client_ID)
				{
					boolean found = false;
					for (int i = 0; i < list.size(); i++)
					{
						MWFNode existing = (MWFNode)list.get(i);
						if (existing.getAD_WF_Node_ID() == node.getAD_WF_Node_ID())
						{
							found = true;
							break;
						}
					}
					if (!found)
					{
						log.log(Level.WARNING, "Added Node w/o transition: " + node);
						list.add(node);
					}
				}
			}
		}
		//
		MWFNode[] nodeArray = new MWFNode [list.size()];
		list.toArray(nodeArray);
		return nodeArray;
	}	//	getNodesInOrder
	/**
	 * 	Add Nodes recursively (sibling first) to Ordered List
	 *  @param list list to add to
	 * 	@param AD_WF_Node_ID start node id
	 * 	@param AD_Client_ID
	 */
	private void addNodesSF (ArrayList list, int AD_WF_Node_ID, int AD_Client_ID)
	{
		ArrayList tmplist = new ArrayList ();
		MWFNode node = getNode (AD_WF_Node_ID);
		if (node != null 
			&& (node.getAD_Client_ID() == 0 || node.getAD_Client_ID() == AD_Client_ID))
		{
			if (!list.contains(node))
				list.add(node);
			MWFNodeNext[] nexts = node.getTransitions(AD_Client_ID);
			for (int i = 0; i < nexts.length; i++)
			{
				MWFNode child = getNode (nexts[i].getAD_WF_Next_ID());
				if (child == null || !child.isActive())
					continue;
				if (child.getAD_Client_ID() == 0
					|| child.getAD_Client_ID() == AD_Client_ID)
				{
					if (!list.contains(child)){
						list.add(child);
						tmplist.add(child);
					}
				}
			}
			//	Remainder Nodes not connected
			for (int i = 0; i < tmplist.size(); i++)
				addNodesSF (list, tmplist.get(i).get_ID(), AD_Client_ID);
		}
	}	//	addNodesSF
	
	/**
	 * 	Get first transition (Next Node) node id
	 * 	@param AD_WF_Node_ID from node id
	 * 	@param AD_Client_ID 
	 * 	@return next AD_WF_Node_ID or 0
	 */
	public int getNext (int AD_WF_Node_ID, int AD_Client_ID)
	{
		MWFNode[] nodes = getNodesInOrder(AD_Client_ID);
		for (int i = 0; i < nodes.length; i++)
		{
			if (nodes[i].getAD_WF_Node_ID() == AD_WF_Node_ID)
			{
				MWFNodeNext[] nexts = nodes[i].getTransitions(AD_Client_ID);
				if (nexts.length > 0)
					return nexts[0].getAD_WF_Next_ID();
				return 0;
			}
		}
		return 0;
	}	//	getNext
	/**
	 * 	Get Transitions (NodeNext) node id
	 * 	@param AD_WF_Node_ID from node id
	 * 	@param AD_Client_ID
	 * 	@return array of next nodes
	 */
	public MWFNodeNext[] getNodeNexts (int AD_WF_Node_ID, int AD_Client_ID)
	{
		MWFNode[] nodes = getNodesInOrder(AD_Client_ID);
		for (int i = 0; i < nodes.length; i++)
		{
			if (nodes[i].getAD_WF_Node_ID() == AD_WF_Node_ID)
			{
				return nodes[i].getTransitions(AD_Client_ID);
			}
		}
		return null;
	}	//	getNext
	
	/**
	 * 	Get (first) Previous Node ID
	 * 	@param AD_WF_Node_ID from node id
	 * 	@param AD_Client_ID
	 * 	@return next AD_WF_Node_ID or 0
	 */
	public int getPrevious (int AD_WF_Node_ID, int AD_Client_ID)
	{
		MWFNode[] nodes = getNodesInOrder(AD_Client_ID);
		for (int i = 0; i < nodes.length; i++)
		{
			if (nodes[i].getAD_WF_Node_ID() == AD_WF_Node_ID)
			{
				if (i > 0)
					return nodes[i-1].getAD_WF_Node_ID();
				return 0;
			}
		}
		return 0;
	}	//	getPrevious
	/**
	 * 	Get Last Node id
	 * 	@param AD_WF_Node_ID ignored
	 * 	@param AD_Client_ID
	 * 	@return last AD_WF_Node_ID or 0
	 */
	public int getLast (int AD_WF_Node_ID, int AD_Client_ID)
	{
		MWFNode[] nodes = getNodesInOrder(AD_Client_ID);
		if (nodes.length > 0)
			return nodes[nodes.length-1].getAD_WF_Node_ID();
		return 0;
	}	//	getLast
	/**
	 * 	Is this the first Node
	 * 	@param AD_WF_Node_ID node id
	 * 	@param AD_Client_ID
	 * 	@return true if node id is first node
	 */
	public boolean isFirst (int AD_WF_Node_ID, int AD_Client_ID)
	{
		return AD_WF_Node_ID == getAD_WF_Node_ID();
	}	//	isFirst
	/**
	 * 	Is this the last Node
	 * 	@param AD_WF_Node_ID node id
	 * 	@param AD_Client_ID 
	 * 	@return true if node id is last node
	 */
	public boolean isLast (int AD_WF_Node_ID, int AD_Client_ID)
	{
		MWFNode[] nodes = getNodesInOrder(AD_Client_ID);
		return AD_WF_Node_ID == nodes[nodes.length-1].getAD_WF_Node_ID();
	}	//	isLast
	
	/**
	 * 	Get Name
	 * 	@param translated translated
	 * 	@return Name
	 */
	public String getName(boolean translated)
	{
		if (translated && m_translated)
			return m_name_trl;
		return getName();
	}	//	getName
	/**
	 * 	Get Description
	 * 	@param translated translated
	 * 	@return Description
	 */
	public String getDescription (boolean translated)
	{
		if (translated && m_translated)
			return m_description_trl;
		return getDescription();
	}	//	getDescription
	/**
	 * 	Get Help
	 * 	@param translated translated
	 * 	@return Name
	 */
	public String getHelp (boolean translated)
	{
		if (translated && m_translated)
			return m_help_trl;
		return getHelp();
	}	//	getHelp
	/**
	 * 	String Representation
	 *	@return info
	 */
	@Override
	public String toString ()
	{
		StringBuilder sb = new StringBuilder ("MWorkflow[");
		sb.append(get_ID()).append("-").append(getName())
			.append ("]");
		return sb.toString ();
	} //	toString
	
	/**
	 * 	Before Save
	 *	@param newRecord new
	 *	@return true
	 */
	@Override
	protected boolean beforeSave (boolean newRecord)
	{
		validate();
		return true;
	}	//	beforeSave
	
	/**
	 *  After Save.
	 *  @param newRecord new record
	 *  @param success success
	 *  @return true if save complete (if not overwritten true)
	 */
	@Override
	protected boolean afterSave (boolean newRecord, boolean success)
	{
		if (log.isLoggable(Level.FINE)) log.fine("Success=" + success);
		if (!success)
		{
			return false;
		}
		if (newRecord)
		{
			//	save all nodes -- Creating new Workflow
			MWFNode[] nodes = getNodesInOrder(0);
			for (int i = 0; i < nodes.length; i++)
			{
				nodes[i].saveEx(get_TrxName());
			}
		}
		
		if (newRecord)
		{
			int AD_Role_ID = Env.getAD_Role_ID(getCtx());
			MWorkflowAccess wa = new MWorkflowAccess(this, AD_Role_ID);
			wa.saveEx();
		}
		//	Menu/Workflow
		else if (is_ValueChanged("IsActive") || is_ValueChanged(COLUMNNAME_Name) 
			|| is_ValueChanged(COLUMNNAME_Description))
		{
			MMenu[] menues = MMenu.get(getCtx(), "AD_Workflow_ID=" + getAD_Workflow_ID(), get_TrxName());
			for (int i = 0; i < menues.length; i++)
			{
				menues[i].setIsActive(isActive());
				menues[i].setName(getName());
				menues[i].setDescription(getDescription());
				menues[i].saveEx();
			}
		}
		return success;
	}   //  afterSave
	/**
	 * 	Start Workflow.
	 * 	@param pi Process Info (Record_ID)
	 *  @deprecated
	 *	@return process
	 */
	public MWFProcess start (ProcessInfo pi)
	{
		return start(pi, null);
	}
	
	/**
	 * 	Start Workflow.
	 * 	@param pi Process Info
	 *  @param trxName
	 *	@return process
	 */
	public MWFProcess start (ProcessInfo pi, String trxName)
	{
		MWFProcess retValue = null;
		Trx localTrx = null;
		if (trxName == null)
		{
			localTrx = Trx.get(Trx.createTrxName("WFP"), true);
			localTrx.setDisplayName(getClass().getName()+"_start");
		}
		try
		{
			retValue = new MWFProcess (this, pi, trxName != null ? trxName : localTrx.getTrxName());
			retValue.saveEx();
			pi.setSummary(Msg.getMsg(getCtx(), "Processing"));
			retValue.startWork();
			if (localTrx != null)
				localTrx.commit(true);
			retValue.checkCloseActivities(trxName != null ? trxName : localTrx.getTrxName());
		}
		catch (Exception e)
		{
			if (localTrx != null)
				localTrx.rollback();
			log.log(Level.SEVERE, e.getLocalizedMessage(), e);
			StringBuilder msg = new StringBuilder();
			if (retValue != null)
			{
				StateEngine state = retValue.getState();
				if (!Util.isEmpty(retValue.getProcessMsg()) && (state.isTerminated() || state.isAborted()))
				{
					msg.append(retValue.getProcessMsg());
					msg.append("\n");
				}				
			}
			msg.append(e.getMessage());
			pi.setSummary(msg.toString(), true);
			retValue = null;
		}
		finally 
		{
			if (localTrx != null)
				localTrx.close();
		}
		
		if (retValue != null)
		{
			String summary = retValue.getProcessMsg();
			StateEngine state = retValue.getState();
			if (summary == null || summary.trim().length() == 0)
				summary = state.toString();
			pi.setSummary(summary, state.isTerminated() || state.isAborted());
		}
		
		return retValue;
	}	//	MWFProcess
	/**
	 * 	Start Workflow and Wait for completion.
	 * 	@param pi process info with Record_ID record for the workflow
	 *	@return process
	 *  @deprecated workflow process run in the same thread, this is meaningless
	 */
	@Deprecated
	public MWFProcess startWait (ProcessInfo pi)
	{
		final int SLEEP = 500;		//	1/2 sec
		final int MAXLOOPS = 30;	//	15 sec	
		//
		MWFProcess process = start(pi, pi.getTransactionName());
		if (process == null)
			return null;
		Thread.yield();
		StateEngine state = process.getState();
		int loops = 0;
		while (!state.isClosed() && !state.isSuspended())
		{
			if (loops > MAXLOOPS)
			{
				log.warning("Timeout after sec " + ((SLEEP*MAXLOOPS)/1000));
				pi.setSummary(Msg.getMsg(getCtx(), "ProcessRunning"));
				pi.setIsTimeout(true);
				return process;
			}
			try
			{
				Thread.sleep(SLEEP);
				loops++;
			}
			catch (InterruptedException e)
			{
				log.log(Level.SEVERE, "startWait interrupted", e);
				pi.setSummary("Interrupted");
				return process;
			}
			Thread.yield();
			state = process.getState();
		}
		String summary = process.getProcessMsg();
		if (summary == null || summary.trim().length() == 0)
			summary = state.toString();
		pi.setSummary(summary, state.isTerminated() || state.isAborted());
		log.fine(summary);
		return process;
	}	//	startWait
	
	/**
	 * 	Get Duration Base in Seconds
	 *	@return duration unit in seconds
	 */
	public long getDurationBaseSec ()
	{
		if (getDurationUnit() == null)
			return 0;
		else if (DURATIONUNIT_Second.equals(getDurationUnit()))
			return 1;
		else if (DURATIONUNIT_Minute.equals(getDurationUnit()))
			return 60;
		else if (DURATIONUNIT_Hour.equals(getDurationUnit()))
			return 3600;
		else if (DURATIONUNIT_Day.equals(getDurationUnit()))
			return 86400;
		else if (DURATIONUNIT_Month.equals(getDurationUnit()))
			return 2592000;
		else if (DURATIONUNIT_Year.equals(getDurationUnit()))
			return 31536000;
		return 0;
	}	//	getDurationBaseSec
		
	/**
	 * 	Get Duration Calendar Field
	 *	@return Calendar field (Calendar.MINUTE, etc)
	 */
	public int getDurationCalendarField()
	{
		if (getDurationUnit() == null)
			return Calendar.MINUTE;
		else if (DURATIONUNIT_Second.equals(getDurationUnit()))
			return Calendar.SECOND;
		else if (DURATIONUNIT_Minute.equals(getDurationUnit()))
			return Calendar.MINUTE;
		else if (DURATIONUNIT_Hour.equals(getDurationUnit()))
			return Calendar.HOUR;
		else if (DURATIONUNIT_Day.equals(getDurationUnit()))
			return Calendar.DAY_OF_YEAR;
		else if (DURATIONUNIT_Month.equals(getDurationUnit()))
			return Calendar.MONTH;
		else if (DURATIONUNIT_Year.equals(getDurationUnit()))
			return Calendar.YEAR;
		return Calendar.MINUTE;
	}	//	getDurationCalendarField
	
	/**
	 * 	Validate workflow.
	 * 	Sets Valid flag.
	 *	@return errors or ""
	 */
	public String validate()
	{
		StringBuilder errors = new StringBuilder();
		//
		if (getAD_WF_Node_ID() == 0)
			errors.append(" - No Start Node");
		//
		if (WORKFLOWTYPE_DocumentValue.equals(getWorkflowType()) 
			&& (getDocValueLogic() == null || getDocValueLogic().length() == 0))
			errors.append(" - No Document Value Logic");
		//
		if (getWorkflowType().equals(MWorkflow.WORKFLOWTYPE_Manufacturing))
		{
			this.setAD_Table_ID(0);
		}
			
		//	final
		boolean valid = errors.length() == 0;
		setIsValid(valid);
		if (!valid)
			if (log.isLoggable(Level.INFO)) log.info("validate: " + errors);
		return errors.toString();
	}	//	validate
	
	/**
	 * Get AD_Workflow_ID for given M_Product_ID
	 * @param product
	 * @return AD_Workflow_ID
	 */
	public static int getWorkflowSearchKey(MProduct product)
	{
		int AD_Client_ID = Env.getAD_Client_ID(product.getCtx());
		String sql = "SELECT AD_Workflow_ID FROM AD_Workflow "
						+" WHERE Value = ? AND AD_Client_ID = ?";
		return DB.getSQLValueEx(null, sql, product.getValue(), AD_Client_ID);
	}
	/**
	 * Check if this workflow is valid for given date
	 * @param date
	 * @return true if valid for given date
	 */
	public boolean isValidFromTo(Timestamp date)
	{
		Timestamp validFrom = getValidFrom();
		Timestamp validTo = getValidTo();
		
		if (validFrom != null && date.before(validFrom))
			return false;
		if (validTo != null && date.after(validTo))
			return false;
		return true;
	}
	@Override
	public MWorkflow markImmutable() 
	{
		if (is_Immutable())
			return this;
		
		makeImmutable();
		if (m_nodes != null && m_nodes.size() > 0)
			m_nodes.stream().forEach(e -> e.markImmutable()); 
		return this;
	}
	/**
	 * Run document action workflow
	 * @param po
	 * @param docAction DocAction.ACTION_*
	 * @return ProcessInfo
	 */
	public static ProcessInfo runDocumentActionWorkflow(PO po, String docAction)
	{		
		int AD_Table_ID = po.get_Table_ID();
		MTable table = MTable.get(Env.getCtx(), AD_Table_ID);
		MColumn column = table.getColumn("DocAction");
		if (column == null)
			return null;
		if (!docAction.equals(po.get_Value(column.getColumnName())))
		{
			po.set_ValueOfColumn(column.getColumnName(), docAction);
			po.saveEx();
		}
		ProcessInfo processInfo = new ProcessInfo (((DocAction)po).getDocumentInfo(),column.getAD_Process_ID(),po.get_Table_ID(),po.get_ID());
		processInfo.setTransactionName(po.get_TrxName());
		processInfo.setPO(po);
		ServerProcessCtl.process(processInfo, !Util.isEmpty(processInfo.getTransactionName(), true) ? Trx.get(processInfo.getTransactionName(), false) : null);
		return processInfo;
	}
}	//	MWorkflow_ID