diff --git a/customer-work/src/main/java/com/customer/work/service/TaskListenerService.java b/customer-work/src/main/java/com/customer/work/service/TaskListenerService.java deleted file mode 100755 index bae34d6..0000000 --- a/customer-work/src/main/java/com/customer/work/service/TaskListenerService.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.customer.work.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.flowable.eventregistry.api.EventRegistry; -import org.flowable.eventregistry.api.model.EventPayloadTypes; -import org.flowable.eventregistry.api.runtime.EventPayloadInstance; -import org.flowable.eventregistry.impl.runtime.EventInstanceImpl; -import org.flowable.eventregistry.impl.runtime.EventPayloadInstanceImpl; -import org.flowable.eventregistry.model.EventPayload; -import org.flowable.task.service.delegate.DelegateTask; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.Collection; - -@Service -public class TaskListenerService { - private final EventRegistry eventRegistry; - private final ObjectMapper objectMapper; - - public TaskListenerService(EventRegistry eventRegistry, ObjectMapper objectMapper) { - this.eventRegistry = eventRegistry; - this.objectMapper = objectMapper; - } - - public void throwTaskListenerEvent(String rootCaseId, String eventKey, DelegateTask task, String message) { - ObjectNode jsonTask = objectMapper.createObjectNode(); - jsonTask.put("modelId", task.getTaskDefinitionKey()); - jsonTask.put("assignee", task.getAssignee()); - jsonTask.put("name", task.getName()); - - Collection instances = new ArrayList<>(); - instances.add(new EventPayloadInstanceImpl(EventPayload.correlation("caseId", EventPayloadTypes.STRING), rootCaseId)); - instances.add(new EventPayloadInstanceImpl(new EventPayload("taskListenerTask", EventPayloadTypes.JSON), jsonTask)); - instances.add(new EventPayloadInstanceImpl(new EventPayload("taskListenerMessage", EventPayloadTypes.STRING), message)); - - EventInstanceImpl event = new EventInstanceImpl(eventKey, instances, task.getTenantId()); - eventRegistry.sendSystemEventOutbound(event); - } -} diff --git a/customer-work/src/main/java/com/customer/work/service/TaskProcessStarter.java b/customer-work/src/main/java/com/customer/work/service/TaskProcessStarter.java deleted file mode 100644 index f39e71c..0000000 --- a/customer-work/src/main/java/com/customer/work/service/TaskProcessStarter.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.customer.work.service; - -import org.flowable.engine.RuntimeService; -import org.flowable.task.service.delegate.DelegateTask; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -@Component("taskProcessStarter") -public class TaskProcessStarter { - @Autowired - private RuntimeService runtimeService; - - public void start(String processKey, DelegateTask task) { - - Map vars = new HashMap<>(task.getVariables()); // carry over existing vars - vars.put("originTaskId", task.getId()); - vars.put("originTaskName", task.getName()); - - runtimeService.startProcessInstanceByKey(processKey, vars); - } -} diff --git a/customer-work/src/main/java/com/customer/work/service/VarUtils.java b/customer-work/src/main/java/com/customer/work/service/VarUtils.java index d414d85..182bce8 100644 --- a/customer-work/src/main/java/com/customer/work/service/VarUtils.java +++ b/customer-work/src/main/java/com/customer/work/service/VarUtils.java @@ -9,12 +9,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.flowable.cmmn.api.CmmnRuntimeService; import org.flowable.cmmn.api.runtime.CaseInstance; import org.flowable.cmmn.api.runtime.PlanItemInstance; -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.engine.RuntimeService; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.impl.persistence.entity.ExecutionEntity; @@ -23,7 +18,6 @@ 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; @@ -33,29 +27,19 @@ 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')} - * Root variables are supported. - * 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 — PROCESS_STARTED fires a FlowableEngineEntityEvent. - * The execution is retrieved via event.getEntity(). - - * CMMN — CASE_STARTED fires a FlowableEngineEntityEvent.; Instead, when a plan - * The case instance is retrieved via event.getEntity(). + * General-purpose Flowable variable utility bean for tracking variable changes. + * Usable in BPMN process and CMMN case backend expressions: + * ${varUtils.trackVars('root.snapshot', 'order.customer.name,order.total,status')} + * ${varUtils.trackVars(execution, 'root.snapshot', 'status')} — BPMN, explicit scope + * ${varUtils.trackVars(planItemInstance, 'root.snapshot', 'status')} — CMMN, explicit scope + * Variable paths use dot notation; a leading "root." prefix addresses the root + * instance of a nested case/process hierarchy. */ @Component("varUtils") -public class VarUtils implements FlowableEventListener, SmartInitializingSingleton { +public class VarUtils { 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(); @@ -63,72 +47,12 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); } - @Autowired - private ProcessEngineConfiguration processEngineConfiguration; - - @Autowired - private CmmnEngineConfiguration cmmnEngineConfiguration; - @Autowired private RuntimeService runtimeService; @Autowired 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 // ------------------------------------------------------------------------- @@ -136,39 +60,41 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet /** * Checks which of the given comma-separated variable paths changed since the * last call and returns a JSON array describing each change. - * ${varUtils.trackVars('root.snapshot', '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. - * Each subsequent call compares against the previous snapshot and updates it. + * Example call: + * ${varUtils.trackVars(self, 'root.snapshot', 'root.status, status')} * Example return value: - * [{"path":"root.myString","oldValue":"a","newValue":"b"}] + * [{"path":"root.status","oldValue":"a","newValue":"b"}, {"path":"status","oldValue":"c","newValue":"d"}] */ - public ArrayNode trackVars(String snapshotPath, String variablePathsCsv) { + public ArrayNode trackVars(VariableScope currentScope, String snapshotPath, String variablePathsCsv) { - // Check parameters + // Reject blank parameters early — report "no changes" instead of failing, + // so a misconfigured expression cannot break the surrounding process/case. if (snapshotPath == null || snapshotPath.isBlank() || variablePathsCsv == null || variablePathsCsv.isBlank()) { LOGGER.debug("{}.trackVars: empty parameters", getClass().getName()); return MAPPER.createArrayNode(); } - // Get current scope - VariableScope currentScope = getCurrentScope(); + // Without a resolvable scope there is nothing to read from or write to. if (currentScope == null) { LOGGER.debug("{}.trackVars: currentScope not found", getClass().getName()); return MAPPER.createArrayNode(); } - // Create paths list + // Split the CSV into individual variable paths, dropping blanks and whitespace. List paths = Arrays.stream(variablePathsCsv.split(",")) .map(String::trim).filter(s -> !s.isEmpty()).toList(); + // Load the previous values (persisted as a JSON string variable at + // snapshotPath) to compare the current values against. Map oldSnapshot = loadSnapshot(currentScope, snapshotPath); Map newSnapshot = new HashMap<>(); ArrayNode changes = MAPPER.createArrayNode(); - // Loop paths list + // For every tracked path: read the current value, normalize it so it + // compares consistently with the reloaded snapshot, and record a change + // entry whenever old != new. for (String path : paths) { - JsonNode newValue = toJson(readVariableFromPath(currentScope, path)); + JsonNode newValue = normalize(toJson(readVariableFromPath(currentScope, path))); newSnapshot.put(path, newValue); JsonNode oldValue = oldSnapshot.getOrDefault(path, MAPPER.nullNode()); @@ -181,6 +107,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet } } + // Persist the current values as the reference snapshot for the next call. saveSnapshot(currentScope, snapshotPath, newSnapshot); return changes; @@ -190,10 +117,18 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet // Path resolution // ------------------------------------------------------------------------- + /** + * Reads a value via a dot-notation path, e.g. "order.customer.name". + * The first segment names a Flowable variable; the remaining segments navigate + * into that value (maps, lists/arrays by index, JsonNodes, POJOs — see + * {@link #getNestedVariable}). Returns null when any segment cannot be resolved. + */ private Object readVariableFromPath(VariableScope scope, String path) { String[] segments = path.split("\\.", -1); if (segments.length < 1) return null; + // A leading "root." switches to the root instance of the surrounding + // case/process hierarchy before resolving the variable. int startIndex = 0; if ("root".equals(segments[0])) { if (segments.length < 2) return null; @@ -202,6 +137,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet } if (scope == null) return null; + // Read the top-level variable, then walk the remaining segments into it. Object currentValue = scope.getVariable(segments[startIndex]); for (int i = startIndex + 1; i < segments.length; i++) { if (currentValue == null) return null; @@ -210,10 +146,18 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet return currentValue; } + /** + * Writes a value via a dot-notation path (same syntax as + * {@link #readVariableFromPath}). A single-segment path sets a plain Flowable + * variable; a nested path mutates the container inside the top-level variable + * and writes that variable back so the engine persists the change. + */ private void writeVariableToPath(VariableScope scope, String path, Object value) { String[] segments = path.split("\\.", -1); if (segments.length < 1) return; + // A leading "root." redirects the write to the root instance of the + // surrounding case/process hierarchy. VariableScope targetScope; String[] varSegments; if ("root".equals(segments[0])) { @@ -249,6 +193,11 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet targetScope.setVariable(topVar, topValue); } + /** + * Sets one key on a mutable container (Map or ObjectNode). Anything else — + * including POJOs, which are read-only for this bean — is rejected with a + * warning so a bad path never breaks the calling expression. + */ @SuppressWarnings("unchecked") private boolean setNestedValue(Object parent, String key, Object value, String path) { if (parent instanceof Map map) { @@ -268,20 +217,27 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet // Scope resolution // ------------------------------------------------------------------------- - private VariableScope getCurrentScope() { - VariableScope scope = SCOPE.get(); - if (scope == null) { - LOGGER.error("{}.getCurrentScope called without an active Flowable scope", getClass().getName()); - } - return scope; - } - + /** + * Climbs from the given scope to the root VariableScope of the surrounding + * case/process hierarchy, following call-activity parents (BPMN) and + * parent/callback links (CMMN) across engine boundaries. + */ private VariableScope getRootScope(VariableScope scope) { if (scope instanceof DelegateExecution ex) return findBpmnRootScope(ex.getProcessInstanceId()); + if (scope instanceof PlanItemInstance pii) return findCmmnRootScope(pii.getCaseInstanceId()); if (scope instanceof CaseInstance ci) return findCmmnRootScope(ci.getId()); return null; } + /** + * Finds the root scope starting from a BPMN process instance: + * 1. started by a call activity → recurse into the calling process instance, + * 2. started from a CMMN plan item (callback) → continue climbing in the case, + * 3. otherwise this process instance is itself the root. + * NOTE: the returned object is a detached query result used as VariableScope; + * its variable access only works because expression evaluation runs inside an + * active Flowable command context. + */ private VariableScope findBpmnRootScope(String processInstanceId) { if (processInstanceId == null || runtimeService == null) return null; try { @@ -309,6 +265,13 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet } } + /** + * Finds the root scope starting from a CMMN case instance: + * 1. sub-case → recurse into the parent case, + * 2. started from a BPMN call/task (callback) → continue climbing in the process, + * 3. otherwise this case instance is itself the root. + * Same detached-query-result caveat as {@link #findBpmnRootScope}. + */ private VariableScope findCmmnRootScope(String caseInstanceId) { if (caseInstanceId == null || cmmnRuntimeService == null) return null; try { @@ -331,7 +294,16 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet } } + /** + * Resolves one path segment against a value: list/array index, map key, + * JsonNode field (scalars are unwrapped to plain Java values), and finally + * POJO access via getX()/isX() getters or a declared field. + */ private static Object getNestedVariable(Object obj, String segment) { + // Empty segments (e.g. from "a..b" or a trailing dot) cannot address anything. + if (segment == null || segment.isEmpty()) return null; + + // Lists and arrays are addressed by numeric index, e.g. "items.0.name". if (obj instanceof List list) { try { int i = Integer.parseInt(segment); @@ -345,6 +317,9 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet } catch (NumberFormatException ignored) {} } if (obj instanceof Map map) return map.get(segment); + + // JsonNode: unwrap scalar fields to plain Java values so they compare and + // serialize the same way as values read from Map/POJO variables. if (obj instanceof JsonNode jn) { JsonNode node = jn.get(segment); if (node == null || node.isNull()) return null; @@ -355,6 +330,9 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet if (node.isDouble()) return node.asDouble(); return node; } + + // Fallback for POJOs: try bean getters first, then a declared field + // (climbing the class hierarchy). Read-only — writes reject POJO parents. String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1); try { return obj.getClass().getMethod("get" + cap).invoke(obj); @@ -370,6 +348,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet return null; } + /** Looks up a declared field by name, walking up the class hierarchy. */ private static java.lang.reflect.Field findField(Class c, String name) { while (c != null && c != Object.class) { try { return c.getDeclaredField(name); } @@ -382,6 +361,12 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet // Snapshot persistence // ------------------------------------------------------------------------- + /** + * Loads the previous-values snapshot: reads the variable at {@code path} + * (persisted as a JSON string by {@link #saveSnapshot}) and parses it back + * into a path → value-node map. Returns an empty map when there is no + * snapshot yet (first call) or it is unreadable. + */ private Map loadSnapshot(VariableScope scope, String path) { Object raw = readVariableFromPath(scope, path); if (raw == null) return new HashMap<>(); @@ -398,6 +383,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet } } + /** Persists the snapshot map as a JSON string variable at {@code path}. */ private void saveSnapshot(VariableScope scope, String path, Map snapshot) { try { writeVariableToPath(scope, path, MAPPER.writeValueAsString(snapshot)); @@ -410,9 +396,25 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet // Helpers // ------------------------------------------------------------------------- + /** Wraps any Java value into a JsonNode (null-safe). */ private static JsonNode toJson(Object value) { if (value == null) return MAPPER.nullNode(); if (value instanceof JsonNode jn) return jn; return MAPPER.valueToTree(value); } + + /** + * Serializes and re-parses a node so its value types match what + * {@link #loadSnapshot} produces when reading the persisted snapshot back + * (e.g. a Long 5 read from a variable and an Integer 5 parsed from the + * snapshot JSON both become the same numeric node). Without this, unchanged + * numeric values would be reported as changes on every call. + */ + private static JsonNode normalize(JsonNode node) { + try { + return MAPPER.readTree(MAPPER.writeValueAsString(node)); + } catch (Exception e) { + return node; + } + } } diff --git a/customer-work/src/main/resources/application.properties b/customer-work/src/main/resources/application.properties index 2366434..7d617da 100644 --- a/customer-work/src/main/resources/application.properties +++ b/customer-work/src/main/resources/application.properties @@ -34,8 +34,12 @@ flowable.platform.enable-latest-form-definition-lookup=true # Server URL for REST calls baseUrl=http://localhost:8105 +flowable.security.oauth2.post-logout-redirect-url=http://localhost:8105 +flowable.security.basic-auth.username=admin +flowable.security.basic-auth.password=test # Email: Fake SMTP flowable.mail.server.host=localhost flowable.mail.server.port=2525 + diff --git a/customer-work/src/main/resources/com/flowable/template/asposeLinqTemplate02.docx b/customer-work/src/main/resources/com/flowable/template/asposeLinqTemplate02.docx new file mode 100644 index 0000000..ecd4f38 Binary files /dev/null and b/customer-work/src/main/resources/com/flowable/template/asposeLinqTemplate02.docx differ diff --git a/pom.xml b/pom.xml index e54c424..7cc6471 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ UTF-8 UTF-8 21 - 2025.2.05 + 2025.2.07 false