Initial commit
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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.engine.CmmnEngineConfiguration;
|
||||
import org.flowable.common.engine.api.delegate.event.FlowableEntityEvent;
|
||||
import org.flowable.common.engine.api.delegate.event.FlowableEvent;
|
||||
import org.flowable.common.engine.api.delegate.event.FlowableEventListener;
|
||||
import org.flowable.common.engine.api.variable.VariableContainer;
|
||||
import org.flowable.engine.ProcessEngineConfiguration;
|
||||
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 — the current execution / plan-item instance is captured
|
||||
* automatically via a Flowable event listener registered on both engines.
|
||||
*
|
||||
* Expression examples:
|
||||
* ${varUtils.get('order.customer.name')}
|
||||
* ${varUtils.track('order.customer.name,order.total,status')}
|
||||
*
|
||||
* How the scope is resolved without a parameter
|
||||
* -----------------------------------------------
|
||||
* Flowable fires ACTIVITY_STARTED (BPMN) or PLAN_ITEM_INSTANCE_STARTED (CMMN)
|
||||
* on the same thread — and strictly before — the service-task expression is
|
||||
* evaluated. onEvent() stores the current VariableContainer in a ThreadLocal.
|
||||
* The ThreadLocal is cleared when the activity / plan item completes or is
|
||||
* cancelled, so it never leaks across tasks.
|
||||
*
|
||||
* Registration
|
||||
* ------------
|
||||
* SmartInitializingSingleton.afterSingletonsInstantiated() runs after all
|
||||
* Spring beans (including engine configurations) are fully initialised, so
|
||||
* addEventListener() is always called on a live event dispatcher.
|
||||
* Only the engine configurations that are present in the application context
|
||||
* are registered; the other one is silently skipped.
|
||||
*/
|
||||
@Component("varUtils")
|
||||
public class VarUtils implements FlowableEventListener, SmartInitializingSingleton {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class);
|
||||
private static final String SNAPSHOT_PREFIX = "__vartracker__";
|
||||
|
||||
private static final ThreadLocal<VariableContainer> 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;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Listener registration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@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 FlowableEntityEvent entityEvent)) return;
|
||||
Object entity = entityEvent.getEntity();
|
||||
if (!(entity instanceof VariableContainer vc)) return;
|
||||
|
||||
String eventName = event.getType().name();
|
||||
LOGGER.info("eventName={}", eventName);
|
||||
|
||||
switch (eventName) {
|
||||
case "CASE_STARTED",
|
||||
"PROCESS_STARTED",
|
||||
"ACTIVITY_STARTED",
|
||||
"PLAN_ITEM_INSTANCE_STARTED" -> SCOPE.set(vc);
|
||||
case "CASE_ENDED",
|
||||
"PROCESS_COMPLETED",
|
||||
"ACTIVITY_COMPLETED",
|
||||
"ACTIVITY_CANCELLED",
|
||||
"PLAN_ITEM_INSTANCE_COMPLETED",
|
||||
"PLAN_ITEM_INSTANCE_TERMINATED",
|
||||
"PLAN_ITEM_INSTANCE_SUSPENDED" -> SCOPE.remove();
|
||||
default -> { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
VariableContainer scope = currentScope("get");
|
||||
if (scope == null || path == null || path.isBlank()) return null;
|
||||
return resolvePath(scope, path.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks which of the given comma-separated variable paths changed since the
|
||||
* last call and returns a JSON array describing each change.
|
||||
*
|
||||
* ${varUtils.track('order.customer.name,order.total,status')}
|
||||
*
|
||||
* Returns [] on the first call (baseline snapshot recorded).
|
||||
* Each subsequent call compares against the previous snapshot and updates it.
|
||||
* The snapshot is stored inside the variable scope so it survives across tasks.
|
||||
*
|
||||
* Example return value:
|
||||
* [{"path":"order.total","oldValue":100,"newValue":150}]
|
||||
*/
|
||||
public String track(String pathsCsv) {
|
||||
VariableContainer scope = currentScope("track");
|
||||
if (scope == null || pathsCsv == null || pathsCsv.isBlank()) return "[]";
|
||||
|
||||
List<String> paths = Arrays.stream(pathsCsv.split(","))
|
||||
.map(String::trim).filter(s -> !s.isEmpty()).toList();
|
||||
|
||||
String snapshotKey = SNAPSHOT_PREFIX + pathsCsv.replaceAll("\\s+", "");
|
||||
Map<String, JsonNode> previous = loadSnapshot(scope, snapshotKey);
|
||||
Map<String, JsonNode> current = new HashMap<>();
|
||||
ArrayNode changes = MAPPER.createArrayNode();
|
||||
|
||||
for (String path : paths) {
|
||||
JsonNode value = toJson(resolvePath(scope, path));
|
||||
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, snapshotKey, current);
|
||||
|
||||
try {
|
||||
return MAPPER.writeValueAsString(changes);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("varUtils.track: failed to serialize changes", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Object resolvePath(VariableContainer scope, String path) {
|
||||
String[] segments = path.split("\\.", -1);
|
||||
Object current = scope.getVariable(segments[0]);
|
||||
for (int i = 1; i < segments.length; i++) {
|
||||
if (current == null) return null;
|
||||
current = step(current, segments[i]);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
// POJO: try getter (getX / isX) then field
|
||||
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 static Map<String, JsonNode> loadSnapshot(VariableContainer scope, String key) {
|
||||
Object raw = scope.getVariable(key);
|
||||
if (raw == null) return new HashMap<>();
|
||||
try {
|
||||
String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw);
|
||||
Map<String, Object> flat = MAPPER.readValue(json,
|
||||
MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
||||
Map<String, JsonNode> result = new HashMap<>();
|
||||
flat.forEach((k, v) -> result.put(k, toJson(v)));
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("varUtils: could not load snapshot '{}': {}", key, e.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveSnapshot(VariableContainer scope, String key, Map<String, JsonNode> snapshot) {
|
||||
try {
|
||||
scope.setVariable(key, MAPPER.writeValueAsString(snapshot));
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("varUtils: could not save snapshot '{}'", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private VariableContainer currentScope(String method) {
|
||||
VariableContainer 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
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.api.delegate.DelegatePlanItemInstance;
|
||||
import org.flowable.cmmn.engine.CmmnEngineConfiguration;
|
||||
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.delegate.event.FlowableProcessEngineEvent;
|
||||
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 String SNAPSHOT_PREFIX = "__vartracker__";
|
||||
|
||||
private static final ThreadLocal<VariableScope> 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) {
|
||||
String typeName = event.getType().name();
|
||||
|
||||
// BPMN — ACTIVITY_STARTED fires FlowableActivityEventImpl which implements
|
||||
// FlowableProcessEngineEvent (not FlowableEntityEvent as one might expect).
|
||||
// The execution is accessed via getExecution(), not getEntity().
|
||||
if (event instanceof FlowableProcessEngineEvent pe) {
|
||||
DelegateExecution execution = pe.getExecution();
|
||||
if (execution == null) return;
|
||||
switch (typeName) {
|
||||
case "CASE_STARTED", "PROCESS_STARTED" -> SCOPE.set(execution);
|
||||
case "CASE_ENDED", "PROCESS_COMPLETED" -> SCOPE.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// CMMN — There is no dedicated PLAN_ITEM_INSTANCE_STARTED event type.
|
||||
// The base AbstractEntityManager.update() dispatches ENTITY_UPDATED whenever
|
||||
// a plan item instance's state is persisted. We filter on DelegatePlanItemInstance
|
||||
// and check the state string to bind / unbind the scope.
|
||||
if ("ENTITY_UPDATED".equals(typeName) && event instanceof FlowableEngineEntityEvent ee) {
|
||||
Object entity = ee.getEntity();
|
||||
if (entity instanceof DelegatePlanItemInstance dpi) {
|
||||
String state = dpi.getState();
|
||||
if ("active".equals(state)) {
|
||||
SCOPE.set(dpi);
|
||||
} else if ("completed".equals(state) || "terminated".equals(state)
|
||||
|| "failed".equals(state) || "suspended".equals(state)) {
|
||||
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('order.customer.name,order.total,status')}
|
||||
*
|
||||
* Returns [] on the first call (baseline snapshot recorded).
|
||||
* Each subsequent call compares against the previous snapshot and updates it.
|
||||
* The snapshot is stored inside the variable scope so it survives across tasks.
|
||||
*
|
||||
* Example return value:
|
||||
* [{"path":"order.total","oldValue":100,"newValue":150}]
|
||||
*/
|
||||
public String track(String pathsCsv) {
|
||||
VariableScope scope = currentScope("track");
|
||||
if (scope == null || pathsCsv == null || pathsCsv.isBlank()) return "[]";
|
||||
|
||||
List<String> paths = Arrays.stream(pathsCsv.split(","))
|
||||
.map(String::trim).filter(s -> !s.isEmpty()).toList();
|
||||
|
||||
String snapshotKey = SNAPSHOT_PREFIX + pathsCsv.replaceAll("\\s+", "");
|
||||
Map<String, JsonNode> previous = loadSnapshot(scope, snapshotKey);
|
||||
Map<String, JsonNode> 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, snapshotKey, 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 DelegatePlanItemInstance dpi && cmmnRuntimeService != null)
|
||||
return getFromCmmnRoot(dpi.getCaseInstanceId(), 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 static Map<String, JsonNode> loadSnapshot(VariableScope scope, String key) {
|
||||
Object raw = scope.getVariable(key);
|
||||
if (raw == null) return new HashMap<>();
|
||||
try {
|
||||
String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw);
|
||||
Map<String, Object> flat = MAPPER.readValue(json,
|
||||
MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
||||
Map<String, JsonNode> result = new HashMap<>();
|
||||
flat.forEach((k, v) -> result.put(k, toJson(v)));
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("varUtils: could not load snapshot '{}': {}", key, e.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveSnapshot(VariableScope scope, String key, Map<String, JsonNode> snapshot) {
|
||||
try {
|
||||
scope.setVariable(key, MAPPER.writeValueAsString(snapshot));
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("varUtils: could not save snapshot '{}'", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
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<VariableScope> 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<String> paths = Arrays.stream(pathsCsv.split(","))
|
||||
.map(String::trim).filter(s -> !s.isEmpty()).toList();
|
||||
|
||||
Map<String, JsonNode> previous = loadSnapshot(scope, snapshotPath);
|
||||
Map<String, JsonNode> 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<String, JsonNode> 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<String, Object> flat = MAPPER.readValue(json,
|
||||
MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
||||
Map<String, JsonNode> 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<String, JsonNode> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.customer.work.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.flowable.common.engine.api.variable.VariableContainer;
|
||||
import org.flowable.common.engine.impl.el.function.AbstractFlowableVariableExpressionFunction;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Flowable EL function delegate that reads a variable by dot-separated path.
|
||||
* Works in both BPMN processes and CMMN cases without any explicit scope parameter.
|
||||
*
|
||||
* Flowable's AbstractFlowableVariableExpressionFunction automatically injects the
|
||||
* current VariableContainer (execution / planItemInstance / caseInstance) as the
|
||||
* first argument before the expression is evaluated, so no ThreadLocal or listener
|
||||
* is required.
|
||||
*
|
||||
* Registered automatically by Flowable's Spring Boot integration when it finds a
|
||||
* FlowableFunctionDelegate bean in the application context.
|
||||
*
|
||||
* Usage in a Flowable backend expression (prefixes var / vars / variables all work):
|
||||
* ${var:getPath('myVar')}
|
||||
* ${var:getPath('order.customer.name')}
|
||||
* ${var:getPath('order.lines.0.price')} ← list index by position
|
||||
*
|
||||
* Path resolution per segment:
|
||||
* - java.util.List / array → integer index (e.g. "2")
|
||||
* - java.util.Map → key lookup
|
||||
* - Jackson JsonNode → field lookup, scalar nodes unwrapped to Java types
|
||||
* - POJO → public getter (getX / isX) or field, superclass included
|
||||
*
|
||||
* Returns null when any segment along the path does not exist.
|
||||
*/
|
||||
@Component
|
||||
public class VariableGetPathFunction extends AbstractFlowableVariableExpressionFunction {
|
||||
|
||||
public VariableGetPathFunction() {
|
||||
super(List.of("getPath"), "getPath");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Flowable's EL engine. The {@code container} is injected automatically;
|
||||
* {@code path} is the dot-separated path string supplied in the expression.
|
||||
*/
|
||||
public static Object getPath(VariableContainer container, String path) {
|
||||
if (container == null || path == null || path.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] segments = path.split("\\.", -1);
|
||||
Object current = container.getVariable(segments[0]);
|
||||
|
||||
for (int i = 1; i < segments.length; i++) {
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
current = step(current, segments[i]);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Path navigation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Object step(Object obj, String segment) {
|
||||
|
||||
// List / array index
|
||||
if (obj instanceof List<?> list) {
|
||||
try {
|
||||
int idx = Integer.parseInt(segment);
|
||||
return (idx >= 0 && idx < list.size()) ? list.get(idx) : null;
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
if (obj instanceof Object[] arr) {
|
||||
try {
|
||||
int idx = Integer.parseInt(segment);
|
||||
return (idx >= 0 && idx < arr.length) ? arr[idx] : null;
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
// Map key lookup
|
||||
if (obj instanceof Map<?, ?> map) {
|
||||
return map.get(segment);
|
||||
}
|
||||
|
||||
// Jackson JsonNode — unwrap scalar nodes to plain Java values
|
||||
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;
|
||||
}
|
||||
|
||||
// POJO: try getter (getX / isX), then field with superclass walk
|
||||
try {
|
||||
Method m = obj.getClass().getMethod(
|
||||
"get" + Character.toUpperCase(segment.charAt(0)) + segment.substring(1));
|
||||
return m.invoke(obj);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
Method m = obj.getClass().getMethod(
|
||||
"is" + Character.toUpperCase(segment.charAt(0)) + segment.substring(1));
|
||||
return m.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) {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static java.lang.reflect.Field findField(Class<?> clazz, String name) {
|
||||
while (clazz != null && clazz != Object.class) {
|
||||
try {
|
||||
return clazz.getDeclaredField(name);
|
||||
} catch (NoSuchFieldException e) {
|
||||
clazz = clazz.getSuperclass();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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.engine.delegate.DelegateExecution;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Flowable Spring Boot component for tracking process variable changes across calls.
|
||||
*
|
||||
* Usage in Flowable expression:
|
||||
* ${variableTracker.track(execution, 'order.customer.name,order.total,status')}
|
||||
*
|
||||
* Returns a JSON string with an array of changed variables, each entry containing:
|
||||
* { "path": "order.customer.name", "oldValue": "Alice", "newValue": "Bob" }
|
||||
*
|
||||
* State (the snapshot of previous values) is stored as a process variable keyed by
|
||||
* SNAPSHOT_VAR_PREFIX + the provided paths string, so independent track() calls
|
||||
* with different path sets do not interfere with each other.
|
||||
*/
|
||||
@Component("variableTracker")
|
||||
public class VariableTrackerComponent {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(VariableTrackerComponent.class);
|
||||
private static final String SNAPSHOT_VAR_PREFIX = "__vartracker__";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public VariableTrackerComponent() {
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
this.objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks which of the given variable paths changed since the last call and returns
|
||||
* a JSON array describing each change.
|
||||
*
|
||||
* @param execution the current Flowable execution context
|
||||
* @param pathsCsv comma-separated variable paths, e.g. "order.total,status,user.address.city"
|
||||
* @return JSON string — an array of { path, oldValue, newValue } objects for every changed path
|
||||
*/
|
||||
public String track(DelegateExecution execution, String pathsCsv) {
|
||||
if (pathsCsv == null || pathsCsv.isBlank()) {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
List<String> paths = Arrays.stream(pathsCsv.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
|
||||
String snapshotKey = SNAPSHOT_VAR_PREFIX + pathsCsv.replaceAll("\\s+", "");
|
||||
Map<String, JsonNode> previousSnapshot = loadSnapshot(execution, snapshotKey);
|
||||
Map<String, JsonNode> currentSnapshot = new HashMap<>();
|
||||
|
||||
ArrayNode changes = objectMapper.createArrayNode();
|
||||
|
||||
for (String path : paths) {
|
||||
JsonNode currentValue = resolvePathToJson(execution, path);
|
||||
currentSnapshot.put(path, currentValue);
|
||||
|
||||
if (previousSnapshot.isEmpty()) {
|
||||
// First call — record baseline, no changes reported
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode previousValue = previousSnapshot.getOrDefault(path, objectMapper.nullNode());
|
||||
if (!jsonEquals(previousValue, currentValue)) {
|
||||
ObjectNode change = objectMapper.createObjectNode();
|
||||
change.put("path", path);
|
||||
change.set("oldValue", previousValue);
|
||||
change.set("newValue", currentValue);
|
||||
changes.add(change);
|
||||
}
|
||||
}
|
||||
|
||||
saveSnapshot(execution, snapshotKey, currentSnapshot);
|
||||
|
||||
try {
|
||||
return objectMapper.writeValueAsString(changes);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to serialize changes to JSON", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Variable path resolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves a dot-separated path against the execution's variables and returns
|
||||
* the result as a JsonNode. The first segment is the top-level process variable
|
||||
* name; subsequent segments navigate into the object graph.
|
||||
*/
|
||||
private JsonNode resolvePathToJson(DelegateExecution execution, String path) {
|
||||
String[] segments = path.split("\\.", -1);
|
||||
Object current = execution.getVariable(segments[0]);
|
||||
|
||||
for (int i = 1; i < segments.length; i++) {
|
||||
if (current == null) {
|
||||
return objectMapper.nullNode();
|
||||
}
|
||||
current = getProperty(current, segments[i]);
|
||||
}
|
||||
|
||||
return toJsonNode(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a named property from an object. Supports Maps, Jackson ObjectNodes,
|
||||
* and plain Java objects (via public getter or public field).
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object getProperty(Object obj, String property) {
|
||||
if (obj instanceof Map<?, ?> map) {
|
||||
return map.get(property);
|
||||
}
|
||||
if (obj instanceof ObjectNode on) {
|
||||
JsonNode node = on.get(property);
|
||||
return node != null ? node : null;
|
||||
}
|
||||
if (obj instanceof JsonNode jn) {
|
||||
JsonNode node = jn.get(property);
|
||||
return node != null ? node : null;
|
||||
}
|
||||
// Reflection: try getter first, then field
|
||||
try {
|
||||
String getter = "get" + Character.toUpperCase(property.charAt(0)) + property.substring(1);
|
||||
Method method = obj.getClass().getMethod(getter);
|
||||
return method.invoke(obj);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
String getter = "is" + Character.toUpperCase(property.charAt(0)) + property.substring(1);
|
||||
Method method = obj.getClass().getMethod(getter);
|
||||
return method.invoke(obj);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
java.lang.reflect.Field field = findField(obj.getClass(), property);
|
||||
if (field != null) {
|
||||
field.setAccessible(true);
|
||||
return field.get(obj);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
LOGGER.warn("Could not resolve property '{}' on {}", property, obj.getClass().getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
private java.lang.reflect.Field findField(Class<?> clazz, String name) {
|
||||
while (clazz != null && clazz != Object.class) {
|
||||
try {
|
||||
return clazz.getDeclaredField(name);
|
||||
} catch (NoSuchFieldException e) {
|
||||
clazz = clazz.getSuperclass();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Snapshot persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, JsonNode> loadSnapshot(DelegateExecution execution, String key) {
|
||||
Object raw = execution.getVariable(key);
|
||||
if (raw == null) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
try {
|
||||
String json = raw instanceof String s ? s : objectMapper.writeValueAsString(raw);
|
||||
Map<String, Object> flat = objectMapper.readValue(json,
|
||||
objectMapper.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
||||
Map<String, JsonNode> result = new HashMap<>();
|
||||
for (Map.Entry<String, Object> entry : flat.entrySet()) {
|
||||
result.put(entry.getKey(), toJsonNode(entry.getValue()));
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Could not load variable tracker snapshot for key '{}': {}", key, e.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private void saveSnapshot(DelegateExecution execution, String key, Map<String, JsonNode> snapshot) {
|
||||
try {
|
||||
execution.setVariable(key, objectMapper.writeValueAsString(snapshot));
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Could not save variable tracker snapshot for key '{}'", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private JsonNode toJsonNode(Object value) {
|
||||
if (value == null) {
|
||||
return objectMapper.nullNode();
|
||||
}
|
||||
if (value instanceof JsonNode jn) {
|
||||
return jn;
|
||||
}
|
||||
return objectMapper.valueToTree(value);
|
||||
}
|
||||
|
||||
private boolean jsonEquals(JsonNode a, JsonNode b) {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
return a.equals(b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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.common.engine.api.variable.VariableContainer;
|
||||
import org.flowable.common.engine.impl.el.function.AbstractFlowableVariableExpressionFunction;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Flowable EL function delegate that tracks process / case variable changes across calls.
|
||||
* Works in both BPMN processes and CMMN cases without any explicit scope parameter.
|
||||
*
|
||||
* Flowable's AbstractFlowableVariableExpressionFunction automatically injects the current
|
||||
* VariableContainer as the first argument, so no ThreadLocal, listener, or auto-config
|
||||
* is required. Registered automatically when Flowable finds a FlowableFunctionDelegate
|
||||
* bean in the application context.
|
||||
*
|
||||
* Usage in a Flowable backend expression (prefixes var / vars / variables all work):
|
||||
* ${var:track('order.customer.name,order.total,status')}
|
||||
*
|
||||
* Returns a JSON string with an array of entries for every changed variable:
|
||||
* [{ "path": "order.total", "oldValue": 100, "newValue": 150 }, ...]
|
||||
*
|
||||
* The first call records a baseline snapshot and returns [].
|
||||
* Subsequent calls compare current values against that baseline and update it.
|
||||
* The snapshot is stored inside the variable scope under a hidden key so it persists
|
||||
* across activity boundaries within the same process / case instance.
|
||||
*/
|
||||
@Component
|
||||
public class VariableTrackerComponent extends AbstractFlowableVariableExpressionFunction {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(VariableTrackerComponent.class);
|
||||
private static final String SNAPSHOT_PREFIX = "__vartracker__";
|
||||
|
||||
// ObjectMapper is thread-safe after construction
|
||||
private static final ObjectMapper MAPPER;
|
||||
static {
|
||||
MAPPER = new ObjectMapper();
|
||||
MAPPER.registerModule(new JavaTimeModule());
|
||||
MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
||||
}
|
||||
|
||||
public VariableTrackerComponent() {
|
||||
super(List.of("track"), "track");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EL function — called by Flowable's expression engine
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* {@code container} is injected automatically by Flowable's AST rewriter.
|
||||
* {@code pathsCsv} is the comma-separated path string from the expression.
|
||||
*/
|
||||
public static String track(VariableContainer container, String pathsCsv) {
|
||||
if (container == null || pathsCsv == null || pathsCsv.isBlank()) {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
List<String> paths = Arrays.stream(pathsCsv.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
|
||||
String snapshotKey = SNAPSHOT_PREFIX + pathsCsv.replaceAll("\\s+", "");
|
||||
Map<String, JsonNode> previous = loadSnapshot(container, snapshotKey);
|
||||
Map<String, JsonNode> current = new HashMap<>();
|
||||
|
||||
ArrayNode changes = MAPPER.createArrayNode();
|
||||
|
||||
for (String path : paths) {
|
||||
JsonNode value = resolveToJson(container, path);
|
||||
current.put(path, value);
|
||||
|
||||
if (previous.isEmpty()) {
|
||||
continue; // first call — record baseline only, report nothing
|
||||
}
|
||||
|
||||
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(container, snapshotKey, current);
|
||||
|
||||
try {
|
||||
return MAPPER.writeValueAsString(changes);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to serialize variable changes", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static JsonNode resolveToJson(VariableContainer container, String path) {
|
||||
String[] segments = path.split("\\.", -1);
|
||||
Object current = container.getVariable(segments[0]);
|
||||
|
||||
for (int i = 1; i < segments.length; i++) {
|
||||
if (current == null) return MAPPER.nullNode();
|
||||
current = step(current, segments[i]);
|
||||
}
|
||||
|
||||
return toJson(current);
|
||||
}
|
||||
|
||||
@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) return jn.get(segment);
|
||||
|
||||
// POJO: getter then field
|
||||
try { return obj.getClass().getMethod("get" + cap(segment)).invoke(obj); } catch (Exception ignored) {}
|
||||
try { return obj.getClass().getMethod("is" + cap(segment)).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("variableTracker: cannot resolve segment '{}' on {}", segment, obj.getClass().getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String cap(String s) {
|
||||
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
|
||||
}
|
||||
|
||||
private static java.lang.reflect.Field findField(Class<?> clazz, String name) {
|
||||
while (clazz != null && clazz != Object.class) {
|
||||
try { return clazz.getDeclaredField(name); }
|
||||
catch (NoSuchFieldException e) { clazz = clazz.getSuperclass(); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Snapshot persistence inside the variable scope
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Map<String, JsonNode> loadSnapshot(VariableContainer container, String key) {
|
||||
Object raw = container.getVariable(key);
|
||||
if (raw == null) return new HashMap<>();
|
||||
try {
|
||||
String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw);
|
||||
Map<String, Object> flat = MAPPER.readValue(json,
|
||||
MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
||||
Map<String, JsonNode> result = new HashMap<>();
|
||||
flat.forEach((k, v) -> result.put(k, toJson(v)));
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("variableTracker: could not load snapshot '{}': {}", key, e.getMessage());
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveSnapshot(VariableContainer container, String key, Map<String, JsonNode> snapshot) {
|
||||
try {
|
||||
container.setVariable(key, MAPPER.writeValueAsString(snapshot));
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("variableTracker: could not save snapshot '{}'", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode toJson(Object value) {
|
||||
if (value == null) return MAPPER.nullNode();
|
||||
if (value instanceof JsonNode jn) return jn;
|
||||
return MAPPER.valueToTree(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"key": "hello-world-form",
|
||||
"name": "Hello World Form",
|
||||
"fields": [
|
||||
{
|
||||
"fieldType": "FormField",
|
||||
"id": "helloField",
|
||||
"name": "Hello",
|
||||
"type": "text",
|
||||
"value": "hello",
|
||||
"required": false,
|
||||
"readOnly": false,
|
||||
"overrideId": false
|
||||
},
|
||||
{
|
||||
"fieldType": "FormField",
|
||||
"id": "claudeField",
|
||||
"name": "Claude",
|
||||
"type": "text",
|
||||
"value": "claude",
|
||||
"required": false,
|
||||
"readOnly": false,
|
||||
"overrideId": false
|
||||
}
|
||||
],
|
||||
"outcomes": []
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
http://localhost:8105/action-api/action-repository/action-definitions/key/GKB_A003?formId=FRM-ce76b8bf-27a5-11f1-8af0-3ead6594277e&formFieldId=GKB_F004_work-action1
|
||||
|
||||
http://localhost:8105/#/work/assignee/case/CAS-6d2e2764-325b-11f1-9ab1-3aee1b292dd9
|
||||
#/work/assignee/case/{{$response.executionPayload.id}}
|
||||
|
||||
http://localhost:8105/platform-api/channel-definitions/key/DCL_CH002/events
|
||||
|
||||
|
||||
{{endpoints.platform}}/search/query-case-instances/query/DCL_Q001?start={{$start}}&size={{$pageSize}}&sort={{$sort || 'startTime&order=desc'}}&{{$filter}}
|
||||
{{endpoints.platform}}/search/query-case-instances/query/DCL_Q001?caseDefinitionKey=DCL_C001&start={{$start}}&size={{$pageSize}}&sort={{$sort || 'startTime&order=desc'}}&{{$filter}}
|
||||
|
||||
{{endpoints.platform}}/search/query-case-instances/query/DCL_Q001?caseDefinitionKey=DCL_C009&start={{$start}}&size={{$pageSize}}&sort={{$sort || 'startTime&order=desc'}}&{{$filter}}
|
||||
|
||||
${var:eq(payload.startOnHold, true) ? 'hold' : 'sign'}
|
||||
|
||||
http://localhost:8105/action-api/action-repository/action-definitions/AEN-ef8bdf0f-27ae-11f1-ba38-3ead6594277e/execute?scopeId=CAS-5b051448-3740-11f1-81d8-1eb9efb927bf&scopeType=cmmn
|
||||
|
||||
- ${json:addToArray(jsonArray, json:object())}
|
||||
${json:object()}
|
||||
|
||||
${flw.format.formatString('Content %d' , root.versionNum)}
|
||||
|
||||
${root.versions.size() < root.versionNum ? json:addToArray(root.versions, json:object()) : null}
|
||||
|
||||
${cmmnRuntimeService.updateBusinessStatus(root.id, root.versions[root.versionIndex].)}
|
||||
|
||||
${verifyDecision == 'verified' ? cmmnRuntimeService.updateBusinessStatus(root.id, 'verified') : cmmnRuntimeService.updateBusinessStatus(root.id, 'rejected')}
|
||||
|
||||
root.versions[root.versionIndex]
|
||||
|
||||
${root.businessStatus == 'verified' || root.businessStatus == 'verify'}
|
||||
|
||||
${propertyConfigurationService.getProperty('baseUrl', 'baseUrl')}
|
||||
${''.join('/', myBaseUrl, '#/work/assignee/case', root.id, 'task', myTaskId)}
|
||||
|
||||
${myBaseUrl}/#/work/assignee/case/${root.id}/task/myTaskId
|
||||
|
||||
${myBaseUrl}/#/work/assignee/case/${root.id}/task/${myTaskId}
|
||||
|
||||
http://localhost:8105/platform-api/process-instances?includeTranslations=true&createTestDefinition=false&includeNextTaskInfo=true
|
||||
|
||||
|
||||
Document Generation V${root.versionIndex}: ${root.versions[root.versionIndex].documentGeneration}
|
||||
Verify V${root.versionIndex}
|
||||
Released V${root.versionIndex}
|
||||
|
||||
Verify Decision [${root.versionIndex}]: ${verifyDecision} - Overtime Selection: ${overtimeSelection}
|
||||
|
||||
${root.versions[root.versionIndex].documentGeneration}
|
||||
|
||||
Document Generation [${root.versionIndex}]: ${root.versions[root.versionIndex].documentGeneration}
|
||||
|
||||
Sign Decision [${root.versionIndex}]: ${signDecision} - Overtime Selection: ${overtimeSelection}
|
||||
|
||||
${root.versions[root.versionIndex].workOvertime}
|
||||
|
||||
Print Contract [${root.versionIndex}]
|
||||
|
||||
{{endpoints.platform}}/search/query-case-instances/query/DCL_Q001?caseDefinitionKey=DCL_C135&start={{$start}}&size={{$pageSize}}&sort={{$sort || 'startTime&order=desc'}}&{{$filter}}
|
||||
|
||||
|
||||
{{endpoints.platform}}/search/query-process-instances/query/subitemProcessQuery?processDefinitionKey=subitemSubprocess&start={{$start}}&size={{$pageSize}}&sort={{$sort || 'startTime&order=desc'}}&{{$filter}}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.flowable</groupId>
|
||||
<artifactId>flowable-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.flowable</groupId>
|
||||
<artifactId>flowable-work</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>flowable-work</name>
|
||||
<description>flowable-work</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.flowable.inspect</groupId>
|
||||
<artifactId>flowable-spring-boot-starter-inspect-rest</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.flowable.platform</groupId>
|
||||
<artifactId>flowable-platform-default-models</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.flowable.platform</groupId>
|
||||
<artifactId>flowable-spring-boot-starter-platform-rest</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.flowable.platform</groupId>
|
||||
<artifactId>flowable-tenant-setup</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.flowable</groupId>
|
||||
<artifactId>flowable-platform-bom</artifactId>
|
||||
<version>${com.flowable.platform.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.flowable</groupId>
|
||||
<artifactId>flowable-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.flowable</groupId>
|
||||
<artifactId>flowable-work</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>flowable-work</name>
|
||||
<description>Work 2025.2</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<com.flowable.platform.version>2025.2.02</com.flowable.platform.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.flowable.platform</groupId>
|
||||
<artifactId>flowable-platform-default-models</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.flowable.platform</groupId>
|
||||
<artifactId>flowable-spring-boot-starter-platform-rest</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.flowable.platform</groupId>
|
||||
<artifactId>flowable-tenant-setup</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.flowable.work</groupId>
|
||||
<artifactId>flowable-work-frontend</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.flowable</groupId>
|
||||
<artifactId>flowable-platform-bom</artifactId>
|
||||
<version>${com.flowable.platform.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
Reference in New Issue
Block a user