package com.customer.work.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.flowable.cmmn.api.CmmnRuntimeService; import org.flowable.cmmn.engine.CmmnEngineConfiguration; import org.flowable.cmmn.engine.impl.persistence.entity.CaseInstanceEntity; import org.flowable.common.engine.api.delegate.event.FlowableEngineEntityEvent; import org.flowable.common.engine.api.delegate.event.FlowableEvent; import org.flowable.common.engine.api.delegate.event.FlowableEventListener; import org.flowable.engine.ProcessEngineConfiguration; import org.flowable.cmmn.api.runtime.CaseInstance; import org.flowable.cmmn.api.runtime.PlanItemInstance; import org.flowable.engine.RuntimeService; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.impl.persistence.entity.ExecutionEntity; import org.flowable.engine.runtime.Execution; import org.flowable.engine.runtime.ProcessInstance; import org.flowable.variable.api.delegate.VariableScope; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * General-purpose Flowable variable utility bean. * * Usable in BPMN process and CMMN case backend expressions without any * scope parameter: * ${varUtils.get('order.customer.name')} * ${varUtils.track('order.customer.name,order.total,status')} * * How the scope is resolved without a parameter * ----------------------------------------------- * The bean registers itself as a Flowable event listener on both engines. * Before a service task expression is evaluated, Flowable fires events on the * same thread that allow us to capture the current variable scope: * * BPMN — ACTIVITY_STARTED fires a FlowableProcessEngineEvent; the execution * is retrieved via event.getExecution(). * * CMMN — There is no PLAN_ITEM_INSTANCE_STARTED event. Instead, when a plan * item instance transitions to the ACTIVE state, the base entity manager * calls update(), which dispatches ENTITY_UPDATED carrying the * PlanItemInstanceEntity (implements DelegatePlanItemInstance). * We bind the scope when the state is "active" and clear it when the * state reaches a terminal value. */ @Component("varUtils") public class VarUtils implements FlowableEventListener, SmartInitializingSingleton { private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class); private static final ThreadLocal SCOPE = new ThreadLocal<>(); private static final ObjectMapper MAPPER; static { MAPPER = new ObjectMapper(); MAPPER.registerModule(new JavaTimeModule()); MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); } @Autowired(required = false) private ProcessEngineConfiguration processEngineConfiguration; @Autowired(required = false) private CmmnEngineConfiguration cmmnEngineConfiguration; @Autowired(required = false) private RuntimeService runtimeService; @Autowired(required = false) private CmmnRuntimeService cmmnRuntimeService; // ------------------------------------------------------------------------- // Listener registration — runs after all beans are ready // ------------------------------------------------------------------------- @Override public void afterSingletonsInstantiated() { if (processEngineConfiguration != null) { processEngineConfiguration.getEventDispatcher().addEventListener(this); LOGGER.debug("varUtils registered on BPMN event dispatcher"); } if (cmmnEngineConfiguration != null) { cmmnEngineConfiguration.getEventDispatcher().addEventListener(this); LOGGER.debug("varUtils registered on CMMN event dispatcher"); } } // ------------------------------------------------------------------------- // FlowableEventListener — bind / unbind the scope ThreadLocal // ------------------------------------------------------------------------- @Override public void onEvent(FlowableEvent event) { if (event instanceof FlowableEngineEntityEvent entityEvent) { String typeName = event.getType().name(); Object entity = entityEvent.getEntity(); // BPMN execution if (entity instanceof ExecutionEntity execution) { switch (typeName) { case "PROCESS_STARTED" -> SCOPE.set(execution); case "PROCESS_CANCELLED", "PROCESS_COMPLETED", "PROCESS_COMPLETED_WITH_ERROR_END_EVENT", "PROCESS_COMPLETED_WITH_ESCALATION_END_EVENT", "PROCESS_COMPLETED_WITH_TERMINATE_END_EVENT" -> SCOPE.remove(); } return; } // CMMN case instance itself if (entity instanceof CaseInstanceEntity caseInstance) { if ("CASE_STARTED".equals(typeName)) SCOPE.set(caseInstance); else if ("CASE_ENDED".equals(typeName)) SCOPE.remove(); } } } @Override public boolean isFailOnException() { return false; } @Override public boolean isFireOnTransactionLifecycleEvent() { return false; } @Override public String getOnTransaction() { return null; } // ------------------------------------------------------------------------- // Public API — called from Flowable expressions // ------------------------------------------------------------------------- /** * Returns the value at the given dot-separated path within the current * variable scope. Supports Maps, Lists (integer index), Jackson JsonNodes, * and POJOs (getter or field). Returns null for any missing segment. * * ${varUtils.get('order.customer.name')} * ${varUtils.get('order.lines.0.price')} */ public Object get(String path) { VariableScope scope = currentScope("get"); if (scope == null || path == null || path.isBlank()) return null; return resolvePath(scope, path.trim(), this); } /** * Checks which of the given comma-separated variable paths changed since the * last call and returns a JSON array describing each change. * * ${varUtils.track('root.oldValues', 'root.myString,status')} * * {@code snapshotPath} is a dot-notation path (supports the {@code root.} prefix) * pointing to where the previous-values snapshot is stored and updated. * * Returns [] on the first call (baseline snapshot recorded). * Each subsequent call compares against the previous snapshot and updates it. * * Example return value: * [{"path":"root.myString","oldValue":"a","newValue":"b"}] */ public String track(String snapshotPath, String pathsCsv) { VariableScope scope = currentScope("track"); if (scope == null || snapshotPath == null || snapshotPath.isBlank() || pathsCsv == null || pathsCsv.isBlank()) return "[]"; List paths = Arrays.stream(pathsCsv.split(",")) .map(String::trim).filter(s -> !s.isEmpty()).toList(); Map previous = loadSnapshot(scope, snapshotPath); Map current = new HashMap<>(); ArrayNode changes = MAPPER.createArrayNode(); for (String path : paths) { JsonNode value = toJson(resolvePath(scope, path, this)); current.put(path, value); if (previous.isEmpty()) continue; // first call — baseline only JsonNode prev = previous.getOrDefault(path, MAPPER.nullNode()); if (!prev.equals(value)) { ObjectNode change = MAPPER.createObjectNode(); change.put("path", path); change.set("oldValue", prev); change.set("newValue", value); changes.add(change); } } saveSnapshot(scope, snapshotPath, current); try { return MAPPER.writeValueAsString(changes); } catch (Exception e) { LOGGER.error("varUtils.track: failed to serialize changes", e); return "[]"; } } // ------------------------------------------------------------------------- // Path resolution // ------------------------------------------------------------------------- /** * Resolves a dot-separated path against the current scope. * * If the first segment is the reserved word {@code root}, the entire call * hierarchy is climbed until no parent can be found; the next segment is * then used as the variable name at that topmost scope. * * root.myString → topmost scope, variable "myString" * root.myObj.field → topmost scope, variable "myObj", then navigate to "field" * order.customer.name → current scope, variable "order", navigate normally */ private static Object resolvePath(VariableScope scope, String path, VarUtils self) { String[] segments = path.split("\\.", -1); Object current; int startIdx; if ("root".equals(segments[0])) { if (segments.length < 2) return null; // climb all the way up, then get the variable named by segments[1] current = self.getFromRootScope(scope, segments[1]); startIdx = 2; } else { current = scope.getVariable(segments[0]); startIdx = 1; } for (int i = startIdx; i < segments.length; i++) { if (current == null) return null; current = step(current, segments[i]); } return current; } /** Climbs to the topmost ancestor scope and returns the named variable from there. */ private Object getFromRootScope(VariableScope scope, String varName) { if (scope instanceof DelegateExecution ex && runtimeService != null) return getFromBpmnRoot(ex.getProcessInstanceId(), varName); if (scope instanceof CaseInstance ci && cmmnRuntimeService != null) return getFromCmmnRoot(ci.getId(), varName); return null; } /** * Climbs the hierarchy from a BPMN process instance toward the root. * Parent links: * getSuperExecutionId() → launched via Call Activity (parent is BPMN) * getCallbackId/Type() → launched via Case Task (parent is CMMN) * When no parent exists this IS the root; the variable is fetched here. */ private Object getFromBpmnRoot(String processInstanceId, String varName) { if (processInstanceId == null || runtimeService == null) return null; try { // Parent via Call Activity (BPMN → BPMN) Execution piExec = runtimeService.createExecutionQuery() .executionId(processInstanceId).singleResult(); if (piExec != null && piExec.getSuperExecutionId() != null) { Execution superExec = runtimeService.createExecutionQuery() .executionId(piExec.getSuperExecutionId()).singleResult(); if (superExec != null) return getFromBpmnRoot(superExec.getProcessInstanceId(), varName); } // Parent via Case Task (CMMN → BPMN): callbackId = plan item instance id ProcessInstance pi = runtimeService.createProcessInstanceQuery() .processInstanceId(processInstanceId).singleResult(); if (pi != null && pi.getCallbackType() != null && pi.getCallbackId() != null && cmmnRuntimeService != null) { PlanItemInstance planItem = cmmnRuntimeService.createPlanItemInstanceQuery() .planItemInstanceId(pi.getCallbackId()).singleResult(); if (planItem != null) return getFromCmmnRoot(planItem.getCaseInstanceId(), varName); } // No parent reachable — this is the root BPMN process instance return runtimeService.getVariable(processInstanceId, varName); } catch (Exception e) { LOGGER.debug("varUtils: BPMN root climb failed for '{}': {}", varName, e.getMessage()); return null; } } /** * Climbs the hierarchy from a CMMN case instance toward the root. * Parent links: * getParentId() → launched via Case Task in another case (parent is CMMN) * getCallbackId/Type() → launched via Case Task in a process (parent is BPMN) * When no parent exists this IS the root; the variable is fetched here. */ private Object getFromCmmnRoot(String caseInstanceId, String varName) { if (caseInstanceId == null || cmmnRuntimeService == null) return null; try { CaseInstance ci = cmmnRuntimeService.createCaseInstanceQuery() .caseInstanceId(caseInstanceId).singleResult(); if (ci == null) return null; // Parent via Case Task in another case (CMMN → CMMN) if (ci.getParentId() != null) return getFromCmmnRoot(ci.getParentId(), varName); // Parent via Case Task in a process (BPMN → CMMN): callbackId = execution id if (ci.getCallbackType() != null && ci.getCallbackId() != null && runtimeService != null) { Execution callbackExec = runtimeService.createExecutionQuery() .executionId(ci.getCallbackId()).singleResult(); if (callbackExec != null) return getFromBpmnRoot(callbackExec.getProcessInstanceId(), varName); } // No parent reachable — this is the root CMMN case instance return cmmnRuntimeService.getVariable(caseInstanceId, varName); } catch (Exception e) { LOGGER.debug("varUtils: CMMN root climb failed for '{}': {}", varName, e.getMessage()); return null; } } @SuppressWarnings("unchecked") private static Object step(Object obj, String segment) { if (obj instanceof List list) { try { int i = Integer.parseInt(segment); return i >= 0 && i < list.size() ? list.get(i) : null; } catch (NumberFormatException ignored) {} } if (obj instanceof Object[] arr) { try { int i = Integer.parseInt(segment); return i >= 0 && i < arr.length ? arr[i] : null; } catch (NumberFormatException ignored) {} } if (obj instanceof Map map) return map.get(segment); if (obj instanceof JsonNode jn) { JsonNode node = jn.get(segment); if (node == null || node.isNull()) return null; if (node.isTextual()) return node.asText(); if (node.isBoolean()) return node.asBoolean(); if (node.isLong()) return node.asLong(); if (node.isInt()) return node.asInt(); if (node.isDouble()) return node.asDouble(); return node; } String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1); try { return obj.getClass().getMethod("get" + cap).invoke(obj); } catch (Exception ignored) {} try { return obj.getClass().getMethod("is" + cap).invoke(obj); } catch (Exception ignored) {} try { java.lang.reflect.Field f = findField(obj.getClass(), segment); if (f != null) { f.setAccessible(true); return f.get(obj); } } catch (Exception ignored) {} LOGGER.warn("varUtils: cannot resolve '{}' on {}", segment, obj.getClass().getName()); return null; } private static java.lang.reflect.Field findField(Class c, String name) { while (c != null && c != Object.class) { try { return c.getDeclaredField(name); } catch (NoSuchFieldException e) { c = c.getSuperclass(); } } return null; } // ------------------------------------------------------------------------- // Snapshot persistence // ------------------------------------------------------------------------- private Map loadSnapshot(VariableScope scope, String path) { Object raw = resolvePath(scope, path, this); if (raw == null) return new HashMap<>(); try { String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw); Map flat = MAPPER.readValue(json, MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class)); Map result = new HashMap<>(); flat.forEach((k, v) -> result.put(k, toJson(v))); return result; } catch (Exception e) { LOGGER.warn("varUtils: could not load snapshot at '{}': {}", path, e.getMessage()); return new HashMap<>(); } } private void saveSnapshot(VariableScope scope, String path, Map snapshot) { try { writeToPath(scope, path, MAPPER.writeValueAsString(snapshot)); } catch (Exception e) { LOGGER.error("varUtils: could not save snapshot at '{}'", path, e); } } /** * Writes {@code value} to the location described by {@code path}. * Supports the {@code root.} prefix (climbs to the topmost ancestor scope). * Only single-segment variable names are supported after the optional prefix. */ private void writeToPath(VariableScope scope, String path, Object value) { String[] parts = path.split("\\.", 2); if (parts.length == 2 && "root".equals(parts[0])) { if (scope instanceof DelegateExecution ex && runtimeService != null) setAtBpmnRoot(ex.getProcessInstanceId(), parts[1], value); else if (scope instanceof CaseInstance ci && cmmnRuntimeService != null) setAtCmmnRoot(ci.getId(), parts[1], value); } else { scope.setVariable(parts[0], value); } } /** Climbs to the root BPMN process instance and sets the variable there. */ private void setAtBpmnRoot(String processInstanceId, String varName, Object value) { if (processInstanceId == null || runtimeService == null) return; try { Execution piExec = runtimeService.createExecutionQuery() .executionId(processInstanceId).singleResult(); if (piExec != null && piExec.getSuperExecutionId() != null) { Execution superExec = runtimeService.createExecutionQuery() .executionId(piExec.getSuperExecutionId()).singleResult(); if (superExec != null) { setAtBpmnRoot(superExec.getProcessInstanceId(), varName, value); return; } } ProcessInstance pi = runtimeService.createProcessInstanceQuery() .processInstanceId(processInstanceId).singleResult(); if (pi != null && pi.getCallbackType() != null && pi.getCallbackId() != null && cmmnRuntimeService != null) { PlanItemInstance planItem = cmmnRuntimeService.createPlanItemInstanceQuery() .planItemInstanceId(pi.getCallbackId()).singleResult(); if (planItem != null) { setAtCmmnRoot(planItem.getCaseInstanceId(), varName, value); return; } } runtimeService.setVariable(processInstanceId, varName, value); } catch (Exception e) { LOGGER.debug("varUtils: BPMN root write failed for '{}': {}", varName, e.getMessage()); } } /** Climbs to the root CMMN case instance and sets the variable there. */ private void setAtCmmnRoot(String caseInstanceId, String varName, Object value) { if (caseInstanceId == null || cmmnRuntimeService == null) return; try { CaseInstance ci = cmmnRuntimeService.createCaseInstanceQuery() .caseInstanceId(caseInstanceId).singleResult(); if (ci == null) return; if (ci.getParentId() != null) { setAtCmmnRoot(ci.getParentId(), varName, value); return; } if (ci.getCallbackType() != null && ci.getCallbackId() != null && runtimeService != null) { Execution callbackExec = runtimeService.createExecutionQuery() .executionId(ci.getCallbackId()).singleResult(); if (callbackExec != null) { setAtBpmnRoot(callbackExec.getProcessInstanceId(), varName, value); return; } } cmmnRuntimeService.setVariable(caseInstanceId, varName, value); } catch (Exception e) { LOGGER.debug("varUtils: CMMN root write failed for '{}': {}", varName, e.getMessage()); } } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private VariableScope currentScope(String method) { VariableScope scope = SCOPE.get(); if (scope == null) { LOGGER.error("varUtils.{}() called without an active Flowable scope — " + "is VarUtils registered on the engine event dispatcher?", method); } return scope; } private static JsonNode toJson(Object value) { if (value == null) return MAPPER.nullNode(); if (value instanceof JsonNode jn) return jn; return MAPPER.valueToTree(value); } }