# OKMFlow Workflow Engine 1.8 — Workflow Examples

> Collection of real workflow examples for OKMFlow 1.8. Each example includes the process description, the nodes and transitions that compose it, the relevant Groovy scripts, and the XML forms associated with the tasks. These are representative use cases that illustrate the most common patterns of the workflow engine.

---

## Node positioning conventions

Nodes are positioned using `posX` / `posY` coordinates in the `.okmflow` file. The following distances ensure the diagram is readable and transitions do not overlap.

### Vertical spacing between sequential nodes

| Source node type            | Recommended Δy |
|-----------------------------|----------------|
| Start → first node          | 150 px |
| Task → Action (or vice versa) | 150–200 px |
| Task → Task                 | 175–200 px |
| Last branch node → End      | 180–200 px |

### Parallel branches (when a Task has more than one outgoing transition)

When a node branches into two paths:

1. **Horizontal separation between parallel nodes:** ~270 px between their centres.
2. **Left branch:** `posX = posX_source_node - 135`
3. **Right branch:** `posX = posX_source_node + 135`
4. **Δy from branching node to parallel nodes:** 250–270 px.
5. **Convergence node (End or other):** `posX` centred between the two parallel nodes, `Δy` ≈ 180–200 px from them.

### General horizontal positioning

- In a linear flow, nodes can shift slightly in X (±50–100 px) to give a sense of flow, but it is not mandatory.
- In flows with branches, the branching node should be visually centred with respect to its branches.

### Calculation example for a branch

```
Decision node (Task) at posX=400
  → Left branch (Action):   posX = 400 - 135 = 265
  → Right branch (Action):  posX = 400 + 135 = 535
  → Convergence node:       posX = (265 + 535) / 2 = 400
```

### Loop-back branches

A branch that loops back into an **earlier** node in the same flow after passing through one or more intermediate nodes (a clarification request, a retry, a "needs more info" round-trip) is a different shape from a simple branch-then-converge: stacking the loop's nodes directly underneath the main path forces the loop-back transition to cross back over other nodes and transitions, making the diagram hard to read.

Instead, offset the loop's nodes to one side of the main path:

1. Place the loop's nodes well clear of the main column — `posX ≈ posX_main_path ± 650–750` (right or left, whichever side has more room).
2. Set `sourcePosition`/`targetPosition` to `left`/`right` (instead of the default `top`/`bottom`) on the nodes that carry the loop-back transition, so the connecting line runs into the side of the main-path node instead of looping around it.
3. If the loop has an alternative terminal exit (e.g. "close without response"), place that End node further along the same side, past the last loop node — not on the opposite side of the main path.

**Calculation example (from the `complaint-management` template — Investigation ↔ Request Additional Information ↔ Register Received Information loop):**

```
Investigation (Task)                  posX=455,  posY=1159   sourcePosition=bottom, targetPosition=top
  → (missing_info)
Request Additional Information (Action) posX=1175, posY=1460   sourcePosition=top,    targetPosition=left
  →
Register Received Information (Task)  posX=1124, posY=981    sourcePosition=left,   targetPosition=bottom
  → (continue_investigation) back to Investigation
  → (close_no_response) End "Closed - No Response"
```

The two loop nodes sit ~670–720 px to the right of the main column (`Investigation`, `Resolution`) and are chained to each other rather than back onto the main column, so the loop-back transition approaches Investigation from the side instead of crossing the main path.

---

## Action script import conventions

The standard import block used across the Action script examples in this document is:

```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;
```

> **`ISO8601` requires an extra import.** `ISO8601` (used for date formatting/parsing, e.g. `ISO8601.formatBasic(...)`) belongs to `com.openkm.sdk4j.util`, which is **not** part of the standard import block above. Always add `import com.openkm.sdk4j.util.*;` explicitly whenever an Action script uses `ISO8601` — this is distinct from `com.openkm.util.*` (other utility classes) and omitting it causes `No such property: ISO8601` at runtime.

> **`TaskInstanceDTO` also requires an extra import.** It belongs to `com.openkm.okmflow.rest.dto`, which is not included in `com.openkm.okmflow.util.*` or `com.openkm.okmflow.bean.*`. Always add `import com.openkm.okmflow.rest.dto.*;` alongside `WorkflowUtils` when calling methods such as `getTaskInstances` that return `TaskInstanceDTO`.

---

## Example 01 — Document Validation

**Folder:** `01-quick-start-guide/`
**Diagram:** `01-quick-start-guide/diagram.png`
**Process file:** `01-quick-start-guide/document validation.okmflow`
**OpenKM metadata:** `01-quick-start-guide/metadata.xml`

### Description

Document validation workflow in OpenKM. The user who starts the process selects the validator, the system assigns metadata to the document, and finally the validator approves or denies. The result is recorded in the document's own metadata group.

### Metadata group used (`metadata.xml`)

Group `okg:validation` with two fields:
- `okp:validation.validator` — text input, readonly. Stores the validator username.
- `okp:validation.status` — readonly select with options: `APPROVED`, `DENIED`, `PENDING`. Select field values in OpenKM are written as a JSON array: `["PENDING"]`.

> For more details on writing metadata via the SDK and value formats by field type, refer to the Java SDK (sdk4j) reference document and the OpenKM metadata reference document.

### Node flow

```
Start → [Task] Choose validator → [Action] Add Metadata → [Task] Validate
                                                                  ├─(approve)→ [Action] Approve → End
                                                                  └─(deny)──→ [Action] Deny    → End
```

---

### Node: Start

Standard start node. No script or form. Unnamed transition to "Choose validator".

---

### Node: Task — "Choose validator"

**Assignment:** `return context.get("initiator");`

The task is assigned to the user who started the workflow. `initiator` is a context variable automatically injected by OKMFlow when the process starts.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <select label="Validator" name="validator" className="com.openkm.plugin.form.values.OptionSelectUserList">
    <validator type="req"/>
  </select>
</workflow-form>
```

The `select` field uses the `className` attribute to delegate option loading to a plugin (`OptionSelectUserList`), which returns the list of system users at runtime. The field is required (`validator type="req"`).

**Transition:** unnamed (single output) → "Add Metadata".

---

### Node: Action — "Add Metadata"

Gets the OpenKM SDK, reads the document UUID and the validator user selected in the previous task. Creates or updates the document's metadata group and stores the validator in the context for use in the next task.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

FileLogger.info("document-validation", ">>Start Add metadata");
OKMWebservices ws = WebservicesHelper.getInstance();
String docUuid = context.get("uuid");
Select selValidator = context.get("validator");
String validatorUser = selValidator.getValue();
context.put("validatorUser", validatorUser);

FileLogger.info("document-validation", "uuid: %s", docUuid);
FileLogger.info("document-validation", "validatorUser: %s", validatorUser);

if (ws.propertyGroup.hasGroup(docUuid, "okg:validation")) {
  Map<String, String> props = new HashMap<>();
  props.put("okp:validation.validator", validatorUser);
  props.put("okp:validation.status", "[\"PENDING\"]");
  ws.propertyGroup.setProperties(docUuid, "okg:validation", props);
  FileLogger.info("document-validation", "Metadata updated");
} else {
  Map<String, String> props = new HashMap<>();
  props.put("okp:validation.validator", validatorUser);
  props.put("okp:validation.status", "[\"PENDING\"]");
  ws.propertyGroup.addGroup(docUuid, "okg:validation", props);
  FileLogger.info("document-validation", "Metadata created");
}

FileLogger.info("document-validation", "<<End Add metadata");
```

**Key points:**
- `context.get("uuid")` — variable automatically injected by OKMFlow with the UUID of the OpenKM node on which the workflow was launched.
- `context.get("validator")` returns the `Select` object saved by the form. Use `.getValue()` to get the selected value.
- `context.put("validatorUser", validatorUser)` — stores the value in the context so the next Task node can use it in its `assignExpr`.
- `ws.propertyGroup.hasGroup()` checks whether the document already has the group. If it does, use `setProperties()`; if not, use `addGroup()` to create it with the initial values.
- Metadata select field values are written as a JSON array string: `"[\"PENDING\"]"`.
- `WebservicesHelper.getInstance()` returns the Java SDK (sdk4j) instance configured with the connection to the OpenKM server.

**Transition:** unnamed (single output) → "Validate".

---

### Node: Task — "Validate"

**Assignment:** `return context.get("validatorUser");`

The task is assigned to the user stored in the context by the previous Action.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <button label="Approve" name="approve_rev" transition="approve" style="yes" color="success" validate="true" />
  <button label="Deny"    name="deny_rev"    transition="deny"    style="no"  color="danger"  validate="true" />
  <input label="Comments" name="test2" type="text" timeFormat="none" placeholder="here" />
</workflow-form>
```

Each `button` references a transition by name via the `transition` attribute. When the button is clicked, the workflow follows that outgoing transition. The `input` field is optional (no `req` validator).

**Named transitions:**
- `approve` → Action node "Approve"
- `deny` → Action node "Deny"

> Transitions **only need a name** when a node has more than one output. If there is only one transition, it can be left unnamed.

---

### Node: Action — "Approve"

Updates the status field of the metadata group to `APPROVED`.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

FileLogger.info("document-validation", ">>Start Approve");

OKMWebservices ws = WebservicesHelper.getInstance();
String docUuid = context.get("uuid");
FileLogger.info("document-validation", "uuid: %s", docUuid);

Map<String, String> props = new HashMap<>();
props.put("okp:validation.status", "[\"APPROVED\"]");
ws.propertyGroup.setProperties(docUuid, "okg:validation", props);
FileLogger.info("document-validation", "<<End Approve");
```

**Transition:** unnamed → End.

---

### Node: Action — "Deny"

Updates the status field of the metadata group to `DENIED`.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

FileLogger.info("document-validation", ">>Start Deny");

OKMWebservices ws = WebservicesHelper.getInstance();
String docUuid = context.get("uuid");
FileLogger.info("document-validation", "uuid: %s", docUuid);

Map<String, String> props = new HashMap<>();
props.put("okp:validation.status", "[\"DENIED\"]");
ws.propertyGroup.setProperties(docUuid, "okg:validation", props);
FileLogger.info("document-validation", "<<End Deny");
```

**Transition:** unnamed → End.

---

### Node: End

Standard end node. No script or form. Terminates the workflow instance.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| Assign task to the workflow initiator | `assignExpr`: `context.get("initiator")` |
| Read document UUID from context | `context.get("uuid")` |
| Load user list in a select field | `className="com.openkm.plugin.form.values.OptionSelectUserList"` |
| Extract value from a Select object in context | `Select sel = context.get("name"); sel.getValue()` |
| Pass a value from one node to another | `context.put("key", value)` in Action, `context.get("key")` in next node |
| Access OpenKM from Groovy via SDK | `WebservicesHelper.getInstance()` |
| Create or update a metadata group | `ws.propertyGroup.hasGroup()` + `addGroup()` / `setProperties()` |
| Select field value in metadata | String in JSON array format: `"[\"PENDING\"]"` |
| Named transitions (decision in Task) | `transition` attribute on `button`, named transitions on the node |
| Logging from a Groovy script | `FileLogger.info("log-name", "message %s", var)` |

---

## Example 03 — Process Variables

**Folder:** `03-process-variables/`
**Diagram:** `03-process-variables/diagram.png`
**Process file:** `03-process-variables/variables-test.okmflow`

### Description

Didactic workflow showing how to use the context as a data exchange area between nodes. The context does not only store fields that the user fills in during tasks — it also holds any Java variables that Groovy scripts need to share throughout the process.

### Node flow

```
Start → [Action] write log 1 → [Task] Choose validator → [Action] write log 2 → End
```

---

### Node: Start

Standard start node. Unnamed transition → "write log 1".

---

### Node: Action — "write log 1"

Reads the `Actor` object of the workflow initiator from the context and writes it to the log. Then stores three variables of different types in the context for later use.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Get the actor from the context variable
Actor actor = context.get("initiator");

// Write the actor in a file log
FileLogger.info("workflow-variables-test", actor.toString());

// Adding new variables in the context
context.put("tmp-var1", "value1");
context.put("tmp-var2", new Integer(1));
context.put("tmp-var3", 1);
```

**Key points:**
- `context.get("initiator")` returns an `Actor` object, not a `String`. It has a `.toString()` method for logging and can be passed directly to a Task node's `assignExpr`.
- The context accepts any serializable Java type: `String`, `Integer`, form objects (`Select`, `Input`…), etc.
- `new Integer(1)` and the literal `1` are equivalent; both are stored as `Integer`.

**Transition:** unnamed → "Choose validator".

---

### Node: Task — "Choose validator"

**Assignment:** `return "okmAdmin";`

The `assignExpr` can return a literal `String` with the username, without needing to read it from the context.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <select label="Validator" name="validator" className="com.openkm.plugin.form.values.OptionSelectUserList">
    <validator type="req"/>
  </select>
</workflow-form>
```

When the user completes the task, the `Select` object of the `validator` field is automatically stored in the context under the key `"validator"`.

**Transition:** unnamed → "write log 2".

---

### Node: Action — "write log 2"

Retrieves the `Select` object saved by the previous task and the three variables created in "write log 1" from the context. Modifies two of them and saves them back.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Get the object select, named "validator" in the context variables
Select select = context.get("validator");

// Write log
FileLogger.info("workflow-variables-test", select.toString());
FileLogger.info("workflow-variables-test", "actorId= '" + select.getValue() + "'");

// Get context variables
String tmpVar1  = (String)  context.get("tmp-var1");
Integer tmpVar2 = (Integer) context.get("tmp-var2");
Integer tmpVar3 = (Integer) context.get("tmp-var3");

// Write log
FileLogger.info("workflow-variables-test", "tmp-var1= '" + tmpVar1 + "'");
FileLogger.info("workflow-variables-test", "tmp-var2= '" + tmpVar2 + "'");
FileLogger.info("workflow-variables-test", "tmp-var3= '" + tmpVar3 + "'");

// Update values in the context
tmpVar2 = tmpVar2 + 1;
tmpVar3 = tmpVar3 + 10;
context.put("tmp-var2", tmpVar2);
context.put("tmp-var3", tmpVar3);
```

**Key points:**
- When reading from the context, an explicit cast to the correct type is required: `(String)`, `(Integer)`, `Select`, etc.
- Form objects (`Select`, `Input`, `Checkbox`…) that the user fills in a Task node are automatically stored in the context when the task is completed and are accessible in subsequent nodes.
- `context.put()` overwrites the value if the key already exists, allowing variables to be updated throughout the process.

**Transition:** unnamed → End.

---

### Node: End

Standard end node. Terminates the workflow instance.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| `initiator` is an `Actor` object, not a `String` | `Actor actor = context.get("initiator")` |
| Assign task to a hardcoded user | `assignExpr`: `return "okmAdmin";` |
| Store variables of any type in the context | `context.put("key", value)` |
| Read variables with explicit cast | `(String) context.get(...)`, `(Integer) context.get(...)` |
| Access the Select object saved by a task | `Select sel = context.get("fieldName"); sel.getValue()` |
| Update a context variable | read → modify → `context.put()` with the same key |
| Context as a data bus between nodes | variables created in Action 1 available in Task and Action 2 |

---

## Example 04 — Task Management (Value mapping between tasks)

**Folder:** `04-task-management/`
**Diagram:** `04-task-management/diagram.png`
**Process file:** `04-task-management/task-management.okmflow`

### Description

Calculator-style workflow illustrating two key patterns: how to pre-fill task fields with values entered in a previous task (the `data` attribute), and how to create form objects programmatically in an Action node to display a calculated result in the next task.

### Node flow

```
Start → [Task] Product description → [Task] VAT → [Action] Calculator → [Task] Results → End
```

---

### Node: Start

Standard start node. Unnamed transition → "Product description".

---

### Node: Task — "Product description"

**Assignment:** `return "okmAdmin";`

The user enters product data. All fields are required; quantity and price also validate that the value is numeric.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <input label="Product name" name="productName" type="text">
    <validator type="req"/>
  </input>
  <input label="Quantity" name="productQuantity" type="text">
    <validator type="req"/>
    <validator type="num"/>
  </input>
  <input label="Unit price" name="productUnitsPrice" type="text">
    <validator type="req"/>
    <validator type="num"/>
  </input>
</workflow-form>
```

When the task is completed, the `Input` objects of the three fields are stored in the context under the keys `productName`, `productQuantity`, and `productUnitsPrice`.

**Transition:** unnamed → "VAT".

---

### Node: Task — "VAT"

**Assignment:** `return "okmAdmin";`

Displays in read-only mode the three values entered in the previous task and allows the user to select the VAT percentage. The `data` attribute on a field instructs the engine to load the value from the context variable with that name.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <input label="Product name"  name="productName"       type="text" readonly="true" data="productName">
  </input>
  <input label="Quantity"      name="productQuantity"   type="text" readonly="true" data="productQuantity">
  </input>
  <input label="Unit price"    name="productUnitsPrice" type="text" readonly="true" data="productUnitsPrice">
  </input>
  <select label="VAT" name="productVAT" type="simple">
    <option label="5%"  value="5"/>
    <option label="10%" value="10"/>
    <option label="21%" value="21"/>
    <validator type="req"/>
  </select>
</workflow-form>
```

**Key points:**
- `data="productName"` instructs the engine to load the value of the `Input` field stored in the context under the key `"productName"`. The field is shown pre-filled and with `readonly="true"` it is not editable.
- The VAT `select` uses inline static options (no `className`), unlike the previous example which used a plugin.
- A field can have multiple `<validator>` elements: all are evaluated.

**Transition:** unnamed → "Calculator".

---

### Node: Action — "Calculator"

Reads form objects from the context, performs the calculations, and builds an `Input` object with the result for the next task to display.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Capture data from the context
Input productNameInput     = (Input)  context.get("productName");
Input productQuantityInput = (Input)  context.get("productQuantity");
Input productUnitsPrice    = (Input)  context.get("productUnitsPrice");
Select vatSelect           = (Select) context.get("productVAT");

String name       = productNameInput.getValue();
String quantity   = productQuantityInput.getValue();
String unitPrice  = productUnitsPrice.getValue();
String choosenVAT = vatSelect.getValue();

// Write in the log
FileLogger.info("task-manager", "Name: %s", name);
FileLogger.info("task-manager", "Quantity: %s", quantity);
FileLogger.info("task-manager", "Unit price: %s", unitPrice);
FileLogger.info("task-manager", "VAT: %s", choosenVAT);

// Calculate values
int subtotal   = Integer.parseInt(quantity) * Integer.parseInt(unitPrice);
int vat        = Integer.parseInt(choosenVAT);
long vatAmount = (subtotal * vat) / 100;
long total     = subtotal + vatAmount;

// Compose the final text
String msg = "";
msg += "Name= " + name + "\n";
msg += "Subtotal= (" + quantity + " x " + unitPrice + ") = " + subtotal + "\n";
msg += "VAT= (" + subtotal + " x " + vat + ") / 100 = " + vatAmount + "\n";
msg += "Total= (" + subtotal + " x " + vatAmount + ")= " + total;

// Write result in the log
FileLogger.info("task-manager", "result: %s", msg);

// Set result in the context to be used in the next task
Input result = new Input();
result.setName("result");
result.setValue(msg);
context.put("result", result);
```

**Key points:**
- Form fields saved by tasks are read from the context with explicit casting: `(Input) context.get("fieldName")`, `(Select) context.get("fieldName")`.
- Use `.getValue()` on the form object to get the value entered by the user.
- For the next task to display a calculated value via `data`, you must build an object of the corresponding field type (`Input`, `TextArea`, etc.), set `.setName()` and `.setValue()` on it, and store it in the context with `context.put()`.

**Transition:** unnamed → "Results".

---

### Node: Task — "Results"

**Assignment:** `return "okmAdmin";`

Displays the calculation result in a read-only field. The `textarea` loads its value from the `Input` object stored in the context under the key `"result"`.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <textarea label="Result" name="result" data="result" readonly="true">
  </textarea>
</workflow-form>
```

**Key point:** the `data` attribute works for any field type (`input`, `textarea`, `select`…). The engine looks for a form object in the context with that key and loads its value.

**Transition:** unnamed → End.

---

### Node: End

Standard end node. Terminates the workflow instance.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| Pre-fill a field with a value from a previous task | `data="fieldName"` + `readonly="true"` on the target field |
| Read an `Input` object from context in an Action | `Input inp = (Input) context.get("fieldName"); inp.getValue()` |
| Select with static options (no plugin) | Inline `<option label="..." value="..."/>` inside `<select>` |
| Multiple validators on a field | `<validator type="req"/>` + `<validator type="num"/>` |
| Create a form object in an Action for the next task | `Input r = new Input(); r.setName("x"); r.setValue("y"); context.put("x", r)` |
| `data` on `textarea` to display a calculated result | `<textarea data="result" readonly="true">` loads the value from the `Input` in context |

---

## Example 05 — Action Management (Document upload, metadata, stamp and move)

**Folder:** `05-action-management/`
**Diagram:** `05-action-management/diagram.png`
**Process file:** `05-action-management/action-management.okmflow`
**OpenKM metadata:** `05-action-management/metadata.xml`

### Description

Workflow that starts without any document associated (no `uuid` in context). It creates a temporary folder, asks the user to upload a document and fill in metadata fields, assigns the metadata to the uploaded document, stamps it (approved or denied), moves it to a destination folder built from the metadata values, and finally deletes the temporary folder.

### Metadata group used (`metadata.xml`)

Group `okg:doc_information` with three select fields:
- `okp:doc_information.type` — document type: `invoice`, `internal`, `proposal`.
- `okp:doc_information.year` — year: 2023–2025.
- `okp:doc_information.month` — month: `01`–`12`.

Select field values are stored in OpenKM as JSON arrays: `["invoice"]`.

### Node flow

```
Start → [Action] Create folder → [Task] Request metadata → [Action] Save metadata
      → [Task] Set the status → [Action] Stamp document → End
```

---

### Node: Start

Standard start node. This workflow starts **without an associated OpenKM node** — `uuid` and `node` are not present in the context at startup. Unnamed transition → "Create folder".

---

### Node: Action — "Create folder"

Creates a uniquely named temporary folder in the repository using a timestamp, gets its UUID, and stores an `Upload` object in the context so the next task's upload field can target that folder via `data`.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.Calendar;
import java.text.SimpleDateFormat;

OKMWebservices ws = WebservicesHelper.getInstance();

// Calculate destination folder using timestamp to ensure uniqueness
String dstFolder = "/okm:root/upload";
Calendar cal = Calendar.getInstance();
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS");
String subFolder = sdf.format(cal.getTime());
dstFolder = dstFolder + "/" + subFolder;

// Create folder if it does not exist
if (!ws.repository.hasNode(dstFolder)) {
  ws.folder.createMissingFolders(dstFolder);
}

// Store an Upload object in context so the next form field (data="dstFolder") targets this folder
String dstUuid = ws.repository.getNodeUuid(dstFolder);
Upload upload = new Upload();
upload.setName("dstFolder");
upload.setFolderUuid(dstUuid);
context.put("dstFolder", upload);
```

**Key points:**
- `ws.repository.hasNode(path)` — checks whether a node exists at a given path.
- `ws.folder.createMissingFolders(path)` — creates all missing folders in the path hierarchy in one call.
- `ws.repository.getNodeUuid(path)` — resolves a repository path to its UUID.
- The `Upload` object is created programmatically and stored in context. The upload field in the next task uses `data="dstFolder"` to load it, which sets the upload destination automatically.
- The timestamp (`yyyyMMdd_HHmmss_SSS`) makes the folder name unique per workflow instance, preventing collisions between concurrent executions.

**Transition:** unnamed → "Request metadata".

---

### Node: Task — "Request metadata"

**Assignment:** `return "test";`

The user selects the document type, year and month, and uploads a file. The upload field targets the temporary folder created in the previous action via the `data` attribute.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <select label="Document type" name="documentType" type="simple">
    <option label="Invoice"        value="invoice"/>
    <option label="Internal paper" value="internal"/>
    <option label="Proposal"       value="proposal"/>
    <validator type="req"/>
  </select>
  <select label="Year" name="year" type="simple">
    <option label="2025" value="2025"/>
    <option label="2024" value="2024"/>
    <option label="2023" value="2023"/>
    <validator type="req"/>
  </select>
  <select label="Month" name="month" type="simple">
    <option label="January"   value="01"/>
    <option label="February"  value="02"/>
    <option label="March"     value="03"/>
    <option label="April"     value="04"/>
    <option label="May"       value="05"/>
    <option label="June"      value="06"/>
    <option label="July"      value="07"/>
    <option label="August"    value="08"/>
    <option label="September" value="09"/>
    <option label="October"   value="10"/>
    <option label="November"  value="11"/>
    <option label="December"  value="12"/>
  </select>
  <upload label="File" name="file" type="create" data="dstFolder">
    <validator type="req"/>
  </upload>
</workflow-form>
```

**Key point:** `data="dstFolder"` on the `<upload>` field loads the `Upload` object stored in context, which has `folderUuid` set. The engine uses this to determine where to upload the file — the user does not need to specify the destination manually.

**Transition:** unnamed → "Save metadata".

---

### Node: Action — "Save metadata"

Lists the documents in the temporary folder (there will be exactly one — the file just uploaded), reads its UUID, assigns the metadata group with the values from the form fields, and renames the document. It also manually sets `uuid` and `node` in the context to associate the workflow with the uploaded document from this point on.

**Special pattern — acting as the form user:** metadata is written using a webservices instance authenticated as the user who filled the form (`test`), not as the system user. This is done by obtaining the user's auth token and creating a dedicated SDK instance.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;
import java.util.Calendar;
import java.text.SimpleDateFormat;
import com.openkm.okmflow.config.Config;
import com.openkm.okmflow.util.ContextWrapper;
import com.openkm.sdk4j.OKMWebservicesFactory;

OKMWebservices ws = WebservicesHelper.getInstance();

// Get the Upload object from context to find the temp folder
Upload upload = context.get("dstFolder");
String dstFolder = upload.getFolderUuid();
List<Document> docList = ws.document.getChildren(dstFolder);

if (docList.size() == 1) {
  Document doc = docList.get(0);
  String uuid = doc.getUuid();

  // Collect metadata values from context (stored as Select objects by the task form)
  Map<String, String> properties = new HashMap();

  Select documentTypeSelect = (Select) context.get("documentType");
  String value = documentTypeSelect.getValue();
  String newName = value;
  value = "[\"" + value + "\"]";   // wrap as JSON array for OpenKM metadata select fields
  properties.put("okp:doc_information.type", value);
  FileLogger.info("action-management", "Type: %s", value);

  Select yearSelect = (Select) context.get("year");
  value = yearSelect.getValue();
  newName += "_" + value;
  value = "[\"" + value + "\"]";
  properties.put("okp:doc_information.year", value);
  FileLogger.info("action-management", "Year: %s", value);

  Select monthSelect = (Select) context.get("month");
  value = monthSelect.getValue();
  newName += "_" + value;
  value = "[\"" + value + "\"]";
  properties.put("okp:doc_information.month", value);
  FileLogger.info("action-management", "Month: %s", value);

  // Obtain a webservices instance acting as the task user (not the system user)
  String userId = "test";
  String actorToken = ws.auth.getUserToken(userId);
  Config cfg = ContextWrapper.getContext().getBean(Config.class);
  OKMWebservices wsActor = OKMWebservicesFactory.getInstance(cfg.OPENKM_REST_URL);
  wsActor.setAuthorizationToken(actorToken);

  // Add or update the metadata group using the actor's identity
  if (!ws.propertyGroup.hasGroup(uuid, "okg:doc_information")) {
    wsActor.propertyGroup.addGroup(uuid, "okg:doc_information", properties);
  } else {
    wsActor.propertyGroup.setProperties(uuid, "okg:doc_information", properties);
  }

  // Rename the document: type_year_month-HHmmss_SSS.ext
  Document uploadDoc = ws.document.getProperties(uuid);
  String name = PathUtils.getName(uploadDoc.getPath());
  String ext = FileUtils.getFileExtension(name);
  Calendar cal = Calendar.getInstance();
  SimpleDateFormat sdf = new SimpleDateFormat("HHmmss_SSS");
  newName += "-" + sdf.format(cal.getTime()) + "." + ext;
  ws.document.rename(uuid, newName);

  // Save the document UUID for use in subsequent nodes
  context.put("docUuid", uuid);

  // Manually associate the workflow with the uploaded document
  context.put("uuid", doc.getUuid());
  context.put("node", doc);
}
```

**Key points:**
- `ws.document.getChildren(folderUuid)` — lists all documents directly inside a folder.
- The metadata select value must be wrapped as a JSON array string before storing: `"[\"invoice\"]"`.
- **Actor-based webservices:** to write metadata as the form user (not the system), obtain an auth token with `ws.auth.getUserToken(userId)`, create a new SDK instance via `OKMWebservicesFactory.getInstance(url)`, and set the token with `wsActor.setAuthorizationToken(token)`.
- `ws.document.rename(uuid, newName)` — renames a document in the repository.
- `context.put("uuid", ...)` and `context.put("node", ...)` — manually sets the node associated with the workflow mid-execution. From this point on, the workflow treats the uploaded document as its associated node.

**Transition:** unnamed → "Set the status".

---

### Node: Task — "Set the status"

**Assignment:** `return "okmAdmin";`

Asks the administrator to choose the document status. The option `value` is the numeric ID of the stamp to apply (not a label).

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <select label="Status" name="status" type="simple">
    <option label="Approved" value="1"/>
    <option label="Denied"   value="2"/>
    <validator type="req"/>
  </select>
</workflow-form>
```

**Key point:** the option `value` is the ID of a stamp configured in OpenKM. The label ("Approved"/"Denied") is what the user sees; the value (`1`/`2`) is the stamp identifier used in the next action.

**Transition:** unnamed → "Stamp document".

---

### Node: Action — "Stamp document"

Stamps the document, reads the metadata to build the destination folder path, moves the document there, and deletes the temporary folder.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

// Get the document UUID saved by the previous action
String uuid = (String) context.get("docUuid");

// Get the stamp ID from the status select
Select statusSelect = (Select) context.get("status");
String value = statusSelect.getValue();
int id = Integer.parseInt(value);   // option value is the stamp ID

// Stamp the document
ws.stamp.stampText(uuid, id);

// Read metadata to build the destination folder path
String dstFolder = "/okm:root/upload";
if (ws.propertyGroup.hasGroup(uuid, "okg:doc_information")) {
  Map<String, String> properties = ws.propertyGroup.getProperties(uuid, "okg:doc_information");
  String docType = properties.get("okp:doc_information.type");
  String year    = properties.get("okp:doc_information.year");
  String month   = properties.get("okp:doc_information.month");

  // Strip JSON array wrapping: ["invoice"] → invoice
  docType = docType.replace("[\"", "").replace("\"]", "");
  year    = year.replace("[\"", "").replace("\"]", "");
  month   = month.replace("[\"", "").replace("\"]", "");

  // Create destination folder hierarchy and move the document
  dstFolder = dstFolder + "/" + docType + "/" + year + "/" + month;
  ws.folder.createMissingFolders(dstFolder);
  String fldUuid = ws.repository.getNodeUuid(dstFolder);
  ws.document.move(uuid, fldUuid);

  // Delete the temporary folder
  Upload upload = context.get("dstFolder");
  String tempFldUuid = upload.getFolderUuid();
  ws.folder.delete(tempFldUuid);
}
```

**Key points:**
- `ws.stamp.stampText(uuid, stampId)` — applies the stamp identified by `stampId` to the document.
- Metadata values read back from OpenKM are in JSON array format (`["invoice"]`). Strip the wrapping before use: `.replace("[\"", "").replace("\"]", "")`.
- `ws.document.move(uuid, targetFolderUuid)` — moves a document to another folder.
- `ws.folder.delete(uuid)` — deletes a folder (must be empty or the SDK handles recursive deletion).
- The temp folder UUID is recovered from the `Upload` object still stored in context under `"dstFolder"`.

**Transition:** unnamed → End.

---

### Node: End

Standard end node. Terminates the workflow instance.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| Workflow without an initial associated node | No `uuid`/`node` in context at Start |
| Create a folder via SDK | `ws.folder.createMissingFolders(path)` |
| Check node existence by path | `ws.repository.hasNode(path)` |
| Path → UUID resolution | `ws.repository.getNodeUuid(path)` |
| Timestamp-based unique temp folder | `new SimpleDateFormat("yyyyMMdd_HHmmss_SSS").format(cal.getTime())` |
| Pre-configure an upload field destination via context | Create `Upload` with `folderUuid` → `context.put("dstFolder", upload)` → `data="dstFolder"` on `<upload>` |
| List documents in a folder | `ws.document.getChildren(folderUuid)` |
| Write metadata as the form user (not system) | `ws.auth.getUserToken(userId)` + `OKMWebservicesFactory` + `setAuthorizationToken()` |
| Wrap value as JSON array for metadata select | `"[\"" + value + "\"]"` |
| Strip JSON array from metadata value | `.replace("[\"", "").replace("\"]", "")` |
| Rename a document | `ws.document.rename(uuid, newName)` |
| Manually associate workflow with a document mid-execution | `context.put("uuid", ...)` + `context.put("node", ...)` |
| Option value as a functional ID (stamp ID) | `<option label="Approved" value="1"/>` — value is the stamp identifier, not a display label |
| Stamp a document | `ws.stamp.stampText(uuid, stampId)` |
| Move a document to a folder | `ws.document.move(uuid, targetFolderUuid)` |
| Delete a temporary folder | `ws.folder.delete(folderUuid)` |

---

## Example 06 — Assign Task Management (Task assignment strategies)

**Folder:** `06-assign-task-management/`
**Diagram:** `06-assign-task-management/diagram.png`
**Process file:** `06-assign-task-management/assign-task.okmflow`

### Description

Workflow that demonstrates the four available task assignment strategies in OKMFlow: assignment from a context variable chosen at workflow launch, assignment to the initiator of the workflow, assignment to a static list of users (pooled task), and assignment to a dynamic list built from a security role.

### Node flow

```
Start → [Task] Revisor → [Task] Assign to initiator → [Task] Fixed assign list
      → [Task] Dynamic assign list → End

(run_config is a special pre-workflow task — displayed before Start fires)
```

---

### Special node: Task — "run_config"

The name `run_config` is reserved. A task with this exact name is displayed to the user **before the workflow actually starts** — it acts as a launch form. Once the user fills it and submits, the engine records the values in the context and then fires the Start node. This task has no outgoing transitions.

**Assignment:** `return "";` — required and always empty for `run_config`, regardless of how the rest of the workflow assigns tasks. See the "Initiation Form" section of the main reference document for the full rule.

The form uses a plugin (`OptionSelectUserList`) to populate the select with the full list of users registered in OpenKM, avoiding hardcoding user IDs in the form.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <select label="Choose user" name="userAssigned" type="simple"
     className="com.openkm.plugin.form.values.OptionSelectUserList">
    <validator type="req"/>
  </select>
</workflow-form>
```

**Key points:**
- The task name `run_config` is what makes this a pre-workflow form — no special attribute or flag is needed. Any task with this exact name behaves this way.
- The `className` attribute on `<select>` delegates option population to a server-side plugin. `OptionSelectUserList` fills the select with all OpenKM users dynamically, so no `<option>` elements are needed.
- The value chosen by the user (`userAssigned`) is stored in the context as a `Select` object and is available to all subsequent nodes.

---

### Node: Start

Standard start node. Transition → "Revisor".

---

### Node: Task — "Revisor"

Assigns the task to the user selected in the `run_config` form. Reads the `Select` object stored in context, extracts the chosen user ID, and returns it.

**Assignment expression:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Get user assigned from the field in the context
Select userAssigned = (Select) context.get("userAssigned");
String userId = userAssigned.getValue();

// Assign the task to the user
return userId;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <input label="Revisor step" name="input" />
</workflow-form>
```

**Key point:** the `assignExpr` returns a `String` — a single user ID. The engine assigns the task exclusively to that user.

**Transition:** unnamed → "Assign to initiator".

---

### Node: Task — "Assign to initiator"

Assigns the task to the user who launched the workflow. The `initiator` context variable is an `Actor` object automatically populated by the engine when the workflow starts.

**Assignment expression:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Get user initiator from the context
Actor actor = (Actor) context.get("initiator");
String userId = actor.getId();

// Assign the task to the user
return userId;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <input label="Assign to initiator step" name="input" />
</workflow-form>
```

**Key point:** `initiator` is an `Actor` object (WorkflowBean) always present in the context from the moment the workflow starts. Cast it explicitly: `(Actor) context.get("initiator")`. Then call `actor.getId()` to get the username string.

**Transition:** unnamed → "Fixed assign list".

---

### Node: Task — "Fixed assign list"

Assigns the task to a hardcoded list of users. When `assignExpr` returns a `List<String>`, the engine creates a **pooled task**: the task appears in the inbox of all listed users, and whichever one self-assigns it (claims it) becomes the sole assignee from that point on.

**Assignment expression:**
```groovy
import java.util.*;

// List of the users
List<String> userList = new ArrayList();
userList.add("okmAdmin");
userList.add("test");

// Assign to a group of users
return userList;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <input label="Fixed assign list step" name="input" />
</workflow-form>
```

**Key point:** returning a `List<String>` instead of a plain `String` turns the task into a pooled task. All listed users see the task in their inbox; one of them claims it, removing it from the others' inboxes.

**Transition:** unnamed → "Dynamic assign list".

---

### Node: Task — "Dynamic assign list"

Assigns the task to all users that belong to a security role in OpenKM (`ROLE_ASSIGN`). Unlike the previous node, the list is computed at runtime from the repository, so adding or removing users from the role changes the assignment behavior without touching the workflow definition.

**Assignment expression:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

// Get users from role
List<String> userList = new ArrayList();
for (CommonUser commonUser : ws.auth.getUsersByRole("ROLE_ASSIGN")) {
  userList.add(commonUser.getId());
}
// Assign to a group of users
return userList;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <input label="Dynamic assign list step" name="input" />
</workflow-form>
```

**Key point:** `ws.auth.getUsersByRole("ROLE_NAME")` returns a `List<CommonUser>`. Iterate with a for-each and call `commonUser.getId()` to get the username string. This produces a pooled task whose membership reflects the current state of the role — no workflow redeployment needed when users join or leave the role.

**Transition:** unnamed → End.

---

### Node: End

Standard end node.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| Pre-workflow launch form (`run_config`) | Task named exactly `run_config`, no outgoing transitions; displayed before Start fires |
| Plugin-populated select (dynamic user list in form) | `className="com.openkm.plugin.form.values.OptionSelectUserList"` on `<select>` — no `<option>` elements needed |
| Assign task to a specific user from context | `assignExpr` returns a `String` — `((Select) context.get("userAssigned")).getValue()` |
| Assign task to the workflow initiator | `assignExpr` returns `((Actor) context.get("initiator")).getId()` |
| Pooled task — static list | `assignExpr` returns a `List<String>` with hardcoded user IDs |
| Pooled task — dynamic list from security role | `assignExpr` calls `ws.auth.getUsersByRole("ROLE_NAME")` and collects `CommonUser.getId()` |
| `initiator` context variable | Always available as `Actor` object from the first node; no Action node needed to populate it |

---

## Example 07 — Transition Management (Purchase request approval with routing and loops)

**Folder:** `07-transition-management/`
**Diagram:** `07-transition-management/diagram.png`
**Process file:** `07-transition-management/transition-management.okmflow`
**OpenKM metadata:** `07-transition-management/metadata.xml`

### Description

A purchase request approval workflow that illustrates named transitions, button-driven transitions in forms, Decision nodes, and re-entry loops. The requester fills in a form before the workflow starts. The engine routes the request to the appropriate department validator (HR, IT, or Sales). Each validator can accept, reject, or request clarification. On clarification, the workflow cycles back to the requester, who can refine the request and re-submit. For Sales, a Decision node auto-approves requests under a cost threshold without human intervention.

### Metadata group used (`metadata.xml`)

Group `okg:purchase_request` (declared `readonly="true"` at group level — the metadata panel is read-only in OpenKM; values are written exclusively via Groovy scripts):
- `okp:purchase_request.department` — select: `hr`, `it`, `sales`.
- `okp:purchase_request.cost` — text input.
- `okp:purchase_request.description` — textarea (512-char column).
- `okp:purchase_request.status` — select: `clarification`, `validate`, `accepted`, `rejected`.

### Node flow

```
run_config (pre-workflow)
Start → [Action] Save metadata ──► HR validation  → [Task] HR validate
                                ──► IT validation  → [Task] IT validation
                                ──► Sales validation → [Decision] Auto validate
                                                         ├─ accept (cost ≤ 200) → [Action] Accept → End
                                                         └─ Sales validation    → [Task] Sales validation

[Task] HR/IT/Sales validate ──► accept       → [Action] Accept → End
                             ──► reject       → [Action] Reject → End
                             ──► clarification → [Action] Clarification → [Task] Clarification form
                                                                           ├─ validate → [Action] Save metadata (loop)
                                                                           └─ reject   → End (Ended by initiator)
```

---

### Special node: Task — "run_config"

Displayed to the requester before the workflow starts. Collects the department, cost and description. `assignExpr` returns `""` (empty string), which means the engine shows it to the user who is launching the workflow.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <select label="Department" name="department" type="simple">
    <option label="Human resources" value="hr"/>
    <option label="IT"              value="it"/>
    <option label="Sales"           value="sales"/>
    <validator type="req"/>
  </select>
  <input label="Cost" name="cost" type="text" timeFormat="none">
    <validator type="req"/>
    <validator type="num"/>
  </input>
  <textarea label="Description" name="description" type="text">
    <validator type="req"/>
  </textarea>
</workflow-form>
```

**Key point:** using two `<validator>` elements on the same field — `type="req"` (required) and `type="num"` (numeric) — enforces both constraints simultaneously.

---

### Node: Start

Standard start node. Unnamed transition → "Save metadata".

---

### Node: Action — "Save metadata"

Reads the form values from context, writes the metadata group (add or update), and **returns a named transition string** to route the workflow to the correct department.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

// Read form values from context
Select deptSelect         = (Select)   context.get("department");
Input  costInput          = (Input)    context.get("cost");
TextArea descriptionArea  = (TextArea) context.get("description");
String uuid               = (String)   context.get("uuid");

String department = deptSelect.getValue();
String cost       = costInput.getValue();
String description = descriptionArea.getValue();

// Build and save metadata
Map<String,String> properties = new HashMap();
properties.put("okp:purchase_request.department", "[\"" + department + "\"]");
properties.put("okp:purchase_request.cost",       cost);
properties.put("okp:purchase_request.description", description);
properties.put("okp:purchase_request.status",     "[\"validate\"]");

if (ws.propertyGroup.hasGroup(uuid, "okg:purchase_request")) {
  ws.propertyGroup.setProperties(uuid, "okg:purchase_request", properties);
} else {
  ws.propertyGroup.addGroup(uuid, "okg:purchase_request", properties);
}

// Return the name of the outgoing transition to follow
if ("hr".equals(department)) {
  return "HR validation";
} else if ("it".equals(department)) {
  return "IT validation";
} else {
  return "Sales validation";
}
```

**Key points:**
- An Action node with multiple named outgoing transitions **must return a `String`** that matches exactly one transition name. The engine follows that transition; the rest are ignored.
- `TextArea` is the form object type for `<textarea>` fields. Cast explicitly: `(TextArea) context.get("description")`.
- The `cost` field (plain text input, not a select) is stored as a plain string in metadata — no JSON array wrapping needed for non-select fields.
- `status` is initialized to `"validate"` on every pass through this node, including re-entries from the clarification loop.
- Each transition on this node can carry an optional script that runs when that transition is taken (useful for logging). See transition scripts below.

**Outgoing transitions (named):**

| Transition name | Color | Destination |
|-----------------|-------|-------------|
| `HR validation` | black | Task: HR validate |
| `IT validation` | black | Task: IT validation |
| `Sales validation` | black | Decision: Auto validate |

Transition scripts run when a named transition is taken. Example (HR validation transition):
```groovy
FileLogger.info("transition-management", "HR validation choosen");
```

---

### Node: Decision — "Auto validate"

A Decision node evaluates a Groovy script and returns the name of the outgoing transition to follow — exactly like an Action node, but with no side effects. It is used purely for routing logic.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
// ... standard imports ...

// Auto-approve if cost is 200 or less
Input costInput = (Input) context.get("cost");
int cost = Integer.parseInt(costInput.getValue());
if (cost <= 200) {
  return "accept";
} else {
  return "Sales validation";
}
```

**Key points:**
- A Decision node has no form and no `assignExpr`. It executes its script and returns a transition name immediately.
- Use it instead of an Action node when the purpose is purely conditional routing with no repository operations.
- The transition named `"accept"` routes to the Accept Action (same node used by the task validators). Transition `"Sales validation"` routes to the Sales validation Task.

---

### Node: Task — "HR validate"

**Assignment:** `return "hrUser";`

Displays cost and description in read-only mode (pre-filled from context) and lets the validator write a reject reason or a clarification request. Three buttons drive the outgoing transition.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <input    label="Cost"                  name="cost"                type="text" readonly="true" data="cost" />
  <textarea label="Description"           name="description"         type="text" readonly="true" data="description" />
  <textarea label="Reject reason"         name="reason"              type="text" />
  <textarea label="Clarification request" name="clarificationRequest" type="text" />
  <button label="Accept"        name="accept"        transition="accept"        style="yes"    color="success" validate="true" />
  <button label="Clarification" name="clarification" transition="clarification" style="change" color="warning" validate="true" />
  <button label="Reject"        name="reject"        transition="reject"
          confirmation="¿Are you sure to reject de request?" style="no" color="danger" validate="true" />
</workflow-form>
```

**Key points — Button element:**
- `transition="<name>"` — the button fires the named outgoing transition directly. No `return` in a script is needed; the button click itself selects the transition.
- `style` — visual hint: `yes` (confirm action), `no` (destructive action), `change` (neutral action).
- `color` — Bootstrap color: `success` (green), `warning` (amber), `danger` (red).
- `validate="true"` — runs form validators before allowing the transition to fire.
- `confirmation="..."` — shows a confirmation dialog before proceeding. Use this on irreversible actions (Reject).
- When buttons are present, the engine uses the clicked button's `transition` value instead of waiting for a `return` from a script.

**Read-only pre-filled fields:**
- `readonly="true"` — field is displayed but not editable.
- `data="cost"` — loads the value from the `Input` object stored in context under the key `"cost"`. Combined with `readonly`, this turns the field into a display-only view of a previously entered value.

**Outgoing transitions:**

| Transition | Color | Destination |
|------------|-------|-------------|
| `accept` | green | Action: Accept |
| `reject` | red | Action: Reject |
| `clarification` | amber | Action: Clarification |

Each transition carries a logging script (e.g., `FileLogger.info("transition-management", "Accepted by HR")`).

> **IT validation** and **Sales validation** tasks are identical in structure to HR validate, with only `assignExpr` changed (`"itUser"` / `"salesUser"`). They are not repeated here.

---

### Node: Action — "Accept"

Updates the document status to `accepted` in the metadata group and routes to End.

**Script:**
```groovy
OKMWebservices ws = WebservicesHelper.getInstance();
String uuid = (String) context.get("uuid");
Map<String,String> properties = ws.propertyGroup.getProperties(uuid, "okg:purchase_request");
properties.put("okp:purchase_request.status", "[\"accepted\"]");
ws.propertyGroup.setProperties(uuid, "okg:purchase_request", properties);
```

**Key point:** `ws.propertyGroup.getProperties(uuid, group)` fetches the current property map. Modify only the fields you want to change, then call `setProperties`. This avoids overwriting fields you did not touch.

**Transition:** unnamed → End.

---

### Node: Action — "Reject"

Updates status to `rejected` and routes to End.

**Script:**
```groovy
OKMWebservices ws = WebservicesHelper.getInstance();
String uuid = (String) context.get("uuid");
Map<String,String> properties = ws.propertyGroup.getProperties(uuid, "okg:purchase_request");
properties.put("okp:purchase_request.status", "[\"rejected\"]");
ws.propertyGroup.setProperties(uuid, "okg:purchase_request", properties);
```

**Transition:** unnamed → End.

---

### Node: Action — "Clarification"

Updates status to `clarification` and routes to the Clarification form task.

**Script:**
```groovy
OKMWebservices ws = WebservicesHelper.getInstance();
String uuid = (String) context.get("uuid");
Map<String,String> properties = ws.propertyGroup.getProperties(uuid, "okg:purchase_request");
properties.put("okp:purchase_request.status", "[\"clarification\"]");
ws.propertyGroup.setProperties(uuid, "okg:purchase_request", properties);
```

**Transition:** unnamed → Task: Clarification form.

---

### Node: Task — "Clarification form"

**Assignment:** `return ((Actor) context.get("initiator")).getId();` — assigned to the user who launched the workflow.

Shows the clarification message from the validator (read-only) and lets the requester update department, cost and description before re-submitting. Two buttons: Clarify (loops back) or Reject (ends the workflow from the requester's side).

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <textarea label="Clarification request" name="clarificationRequest"
            data="clarificationRequest" type="text" readonly="true" />
  <select label="Department" name="department" type="simple" data="department">
    <option label="Human resources" value="hr"/>
    <option label="IT"              value="it"/>
    <option label="Sales"           value="sales"/>
    <validator type="req"/>
  </select>
  <input    label="Cost"        name="cost"        type="text" data="cost" />
  <textarea label="Description" name="description" type="text" data="description" />
  <button label="Clarify" name="accept" transition="validate" style="yes"  color="success" validate="true" />
  <button label="Reject"  name="reject" transition="reject"
          confirmation="¿Are you sure to reject de request?" style="no" color="danger" validate="true" />
</workflow-form>
```

**Key points:**
- `data="clarificationRequest"` on a readonly textarea loads the value stored in context under `"clarificationRequest"` (written by the validator in the previous task). This is how the requester reads the validator's message.
- `data="department"`, `data="cost"`, `data="description"` pre-fill the editable fields with the previously entered values, letting the requester modify them without starting from scratch.
- The `<select>` with `data="department"` pre-selects the previously chosen option.
- The "Clarify" button fires the `validate` transition → routes back to "Save metadata", starting a new approval cycle with the updated values.
- The "Reject" button fires the `reject` transition → routes to the "Ended by initiator" End node (a custom end node — see below).

**Outgoing transitions:**

| Transition | Destination |
|------------|-------------|
| `validate` | Action: Save metadata (re-entry loop) |
| `reject`   | End: Ended by initiator |

---

### Node: End — "workflow.end"

Standard end node reached after Accept or Reject actions.

---

### Node: End — "Ended by initiator"

A **second End node** with a custom name. Reached when the requester rejects the request themselves from the Clarification form. Having two End nodes with distinct names lets external systems or reports distinguish the termination reason.

**Key point:** a workflow can have multiple End nodes. Each terminates the process instance when reached. The `name` field can be any meaningful label — it is recorded in the process instance log.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| Named transitions on Action node | `Save metadata` returns `"HR validation"`, `"IT validation"` or `"Sales validation"` to select the outgoing transition |
| Transition script | Each named transition can carry a Groovy script that runs when that transition is taken (used here for logging) |
| Decision node for pure routing | `Auto validate` runs a script and returns a transition name — no form, no assignment, no repository operations |
| Button-driven transition | `<button transition="accept" .../>` — clicking the button fires the named transition directly |
| Button confirmation dialog | `confirmation="..."` attribute shows a dialog before the transition fires |
| Button visual styling | `style="yes/no/change"` + `color="success/warning/danger"` |
| Read-only field pre-filled from context | `readonly="true" data="fieldName"` — displays a previously entered value without allowing edits |
| Editable field pre-filled from context | `data="fieldName"` without `readonly` — pre-fills but allows the user to modify it |
| Pre-select a `<select>` option via context | `<select data="department">` — the option matching the current context value is pre-selected |
| Multiple validators on a field | `<validator type="req"/>` + `<validator type="num"/>` on the same field |
| Re-entry loop | "Clarification form" → `validate` transition → "Save metadata" — the workflow cycles until accepted or rejected |
| `getProperties` + targeted update | Fetch the full property map, update only one key, then call `setProperties` |
| Multiple End nodes | `workflow.end` (standard) + `Ended by initiator` (named) — distinguishable termination paths |
| `readonly="true"` on metadata group | Declared in `metadata.xml` at group level — the panel is read-only in the UI; writes go through scripts only |

---

## Example 08 — Mail Management (Sending emails via Mail node and via SDK)

**Folder:** `08-mail-management/`
**Diagram:** `08-mail-management/diagram.png`
**Process file:** `08-mail-management/mail-management.okmflow`

### Description

Workflow that demonstrates the two ways to send emails in OKMFlow: using a dedicated **Mail node** (with a Thymeleaf template for the body, resolved against context variables) and using **`ws.mail.sendMail()`** programmatically from an Action node. The workflow also shows how to build application URLs from the repository configuration and how to generate time-limited download tokens for external (unauthenticated) users.

### Node flow

```
Start → [Action] Create global variables → [Task] Notify new document
      → [Action] Prepare email data → [Mail] Send email to OpenKM users
      → [Task] Send email → [Action] Send to external user → End
```

---

### Node: Start

Standard start node. Unnamed transition → "Create global variables".

---

### Node: Action — "Create global variables"

Reads the application URL from the OpenKM repository configuration and derives a set of useful base URLs. Stores them all in context so they are available to subsequent nodes — including the task assignment notification templates.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

// Read the application URL from repository configuration
Configuration configuration = ws.repository.getConfiguration("application.url");
String appUrl = configuration.getValue();
context.put("appUrl", appUrl);

// Build and store derived URLs
String uuid = context.get("uuid");
context.put("docUrl",              appUrl.replace("index", "open/node/") + uuid);
context.put("baseDownloadUrl",     appUrl.replace("index", "download"));
context.put("baseWorkflowTaskUrl", appUrl.replace("index", "open/workflow/"));

// Store the document name
Node node = context.get("node");
String docName = PathUtils.getName(node.getPath());
context.put("docName", docName);

FileLogger.info("mail-management", "Application URL: " + appUrl);
FileLogger.info("mail-management", "Document URL: " + docUrl);
```

**Key points:**
- `ws.repository.getConfiguration("application.url")` — reads a named configuration key from the OpenKM repository. Returns a `Configuration` object; use `.getValue()` to get the string value.
- URL derivation pattern: `appUrl.replace("index", "...")` produces well-known OpenKM endpoint paths from the base URL.
- Common derived URLs:
  - `open/node/<uuid>` — opens the document in the OpenKM UI.
  - `download` — base download endpoint (add query params for specific files).
  - `open/workflow/<taskId>` — opens a specific workflow task in the UI.
- Variables stored here (`baseWorkflowTaskUrl`, `docUrl`, `docName`) are referenced in task assignment notification templates later in the workflow.

**Transition:** unnamed → "Notify new document".

---

### Node: Task — "Notify new document"

**Assignment:** `return ((Actor) context.get("initiator")).getId();`

The initiator selects one or more OpenKM users to notify and optionally adds a message. The user list is populated by a plugin; multi-selection is enabled with `type="multiple"`.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <select label="Users to notify" name="users" type="multiple"
     className="com.openkm.plugin.form.values.OptionSelectUserList">
    <validator type="req"/>
  </select>
  <textarea label="Message" name="message" type="text" />
</workflow-form>
```

**Task assignment notification (sent automatically by the engine when the task is assigned):**
```
notificationSubject: "Hi ${initiator.name} a task have been assigned to you"
notificationBody:
  <p>Hi ${initiator.name}</p>
  <p>A workflow task has been assigned</p>
  <p><a href="${baseWorkflowTaskUrl}${taskInstance.id}">Click here to access</a></p>
```

**Key points:**
- `type="multiple"` on `<select>` — allows the user to select several options. The resulting context value is a semicolon-separated string of selected values (e.g., `"okmAdmin;test;hrUser"`).
- `className="com.openkm.plugin.form.values.OptionSelectUserList"` — populates options with the full OpenKM user list.
- `notificationSubject` / `notificationBody` — these fields on a Task node define the email that the engine sends automatically when the task is assigned. They use Thymeleaf template syntax (`${variable}`).
- Variables available in notification templates: `${initiator.name}`, `${initiator.email}`, `${taskInstance.id}`, `${node.path}`, and any variable stored in context (e.g., `${baseWorkflowTaskUrl}` was stored in the previous action).

**Transition:** unnamed → "Prepare email data".

---

### Node: Action — "Prepare email data"

Converts the multi-value select (semicolon-separated user IDs) into a semicolon-separated string of email addresses and stores it in context as `"emails"`.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

// Get the multi-value select from context
Select users = (Select) context.get("users");
String usersIdSemicolonSeparated = users.getValue();  // e.g. "okmAdmin;test"

// Convert semicolon-separated user IDs to a List<String>
List<String> userList = WorkflowUtils.convertToListFromSelectValue(usersIdSemicolonSeparated);

// Resolve each user ID to their email address
List<String> emailList = new ArrayList();
for (String userId : userList) {
  CommonUser commonUser = ws.auth.getUser(userId);
  emailList.add(commonUser.getEmail());
}

// Join with semicolons — the Mail node uses ";" as email separator
String emails = String.join(";", emailList);

FileLogger.info("mail-management", "Emails: " + emails);
context.put("emails", emails);
```

**Key points:**
- A `type="multiple"` select stores its value as a semicolon-separated string: `"user1;user2;user3"`.
- `WorkflowUtils.convertToListFromSelectValue(str)` — parses the semicolon-separated string into a `List<String>`.
- `ws.auth.getUser(userId)` — returns a `CommonUser` bean. Use `commonUser.getEmail()` to get the user's email address.
- The Mail node requires recipients as a **semicolon-separated** string (not comma-separated). Use `String.join(";", emailList)`.

**Transition:** unnamed → "Send email to OpenKM users".

---

### Node: Mail — "Send email to OpenKM users"

A dedicated **Mail node** sends an email to all addresses stored in `"emails"`. The body is a Thymeleaf template with access to workflow context variables and form objects.

**Mail node fields:**
```
recipients: "${emails}"
subject:    "New document that may be of interest to you"
body:
  <p>Hi</p>
  <p>I would like to inform you that there is a new document that may be of interest to you.</p>
  <p>If you have any comments, you can contact me by email: ${initiator.email}</p>
  <p><strong>The document: ${node.path}</strong> <a href="${docUrl}">Open ${docName}</a></p>
  <p>
    <#if message.value?? && message.value != "">
      <strong>Extra message:</strong>
      ${message.value}
    <#else>
      No message available
    </#if>
  </p>
  <p>Regards</p>
  <p>FYI: ${initiator.name}</p>
```

**Key points — Mail node template variables:**
- `recipients` resolves `"${emails}"` from context → the semicolon-separated email string built by the previous action.
- Standard context objects available in templates: `${initiator.name}`, `${initiator.email}`, `${node.path}`.
- Custom context variables: `${docUrl}`, `${docName}` — any `String` stored in context is directly accessible.
- **Form object fields:** `${message.value}` — accesses the `.value` property of the `TextArea` form object stored in context under the key `"message"`. Form elements expose their value via `.value` in Thymeleaf (not via `.getValue()`).
- **Thymeleaf conditional:** `<#if var?? && var != "">...</#if>` — `??` checks that the variable is not null; the second condition checks it is not empty. Use this to render optional content safely.
- The Mail node has no `script`, no `assignExpr`, and no `form`. It executes automatically and fires its outgoing transition immediately after sending.

**Transition:** unnamed → "Send email".

---

### Node: Task — "Send email"

**Assignment:** `return ((Actor) context.get("initiator")).getId();`

The initiator enters an external email address and optionally refines the message (pre-filled from the previous task's message via `data="message"`).

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <input    label="External mail" name="externalMail" type="text" timeFormat="none">
    <validator type="req"/>
  </input>
  <textarea label="Message" name="message" type="text" data="message" />
</workflow-form>
```

**Key point:** `data="message"` on the textarea pre-fills it with the `TextArea` object from context (written in the earlier "Notify new document" task), so the initiator can reuse or edit the same message for the external recipient.

**Transition:** unnamed → "Send to external user".

---

### Node: Action — "Send to external user"

Builds a tokenized download URL accessible without authentication and sends the email programmatically via the SDK.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

// Collect values from context
String message  = WorkflowUtils.getFormElementValue(context, "message");
String to       = WorkflowUtils.getFormElementValue(context, "externalMail");
Actor  initiator = context.get("initiator");
String docName  = context.get("docName");
String uuid     = context.get("uuid");

// Build a tokenized download URL usable by unauthenticated users
Configuration appUrl = ws.repository.getConfiguration("application.url");
String baseUrl = appUrl.getValue();
String downloadToken = ws.node.generateDownloadToken(uuid, false, false, null);
String downloadUrl = baseUrl.replace("index", "download?DTK=") + downloadToken;

// Compose the email
String from = "noreply@nomail.com";
String subject = "New document that may be of interest to you";
List<String> toList = new ArrayList();
toList.add(to);

String body = "Hi<br/><br/>";
body += "I would like to inform you that there is a new document that may be of interest to you.<br/><br/>";
body += "If you have any comments, you can contact me by email: " + initiator.getEmail() + "<br/><br/>";
body += "The document: <a href=\"" + downloadUrl + "\">Download " + docName + "</a><br/><br/>";
if (message.equals("")) {
  body += "No message available<br/>";
} else {
  body += "Extra message:<br/>" + message + "<br/><br/>";
}
body += "Regards<br/>";
body += "FYI: " + initiator.getName();

// Send email via OpenKM SDK
ws.mail.sendMail(from, toList, subject, body);
```

**Key points:**
- `WorkflowUtils.getFormElementValue(context, "key")` — convenience method that extracts the string value from any form element in context (Input, TextArea, Select…) without needing to cast and call `.getValue()` manually.
- `ws.node.generateDownloadToken(uuid, false, false, null)` — generates a time-limited download token. The resulting URL (`download?DTK=<token>`) lets external users download the document without an OpenKM account.
- `ws.mail.sendMail(from, toList, subject, body)` — sends an email via the OpenKM mail service. `toList` is a `List<String>` of recipient addresses; `body` is an HTML string.
- In an Action node, the email body is built as a plain Java string — no Thymeleaf templating. Use string concatenation and `<br/>` for HTML line breaks.
- `Actor.getEmail()` and `Actor.getName()` — accessor methods on the `Actor` bean (complement to `Actor.getId()`).

**Transition:** unnamed → End.

---

### Node: End

Standard end node.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| Read application URL from repository config | `ws.repository.getConfiguration("application.url").getValue()` |
| Build derived application URLs | `appUrl.replace("index", "open/node/")`, `"download"`, `"open/workflow/"` |
| Store global context variables in a first Action | Centralize URL and name variables once, reuse in all subsequent nodes and templates |
| Task assignment notification template | `notificationSubject` / `notificationBody` fields on Task node; use `${variable}` — context variables are accessible |
| Multi-value select (`type="multiple"`) | User selects multiple options; value stored as `"id1;id2;id3"` |
| Parse multi-value select into a list | `WorkflowUtils.convertToListFromSelectValue(str)` |
| Resolve user ID to email | `ws.auth.getUser(userId).getEmail()` |
| Semicolon-separated email list for Mail node | `String.join(";", emailList)` — Mail node requires `;` as separator |
| Mail node with context variable in `recipients` | `recipients = "${emails}"` resolves the stored string |
| Mail node Thymeleaf body — context variables | `${docUrl}`, `${docName}`, `${initiator.name}` |
| Mail node Thymeleaf body — form object field | `${message.value}` — accesses the `.value` property of a form element via template |
| Mail node Thymeleaf conditional | `<#if var?? && var != "">...</#if>` — null-safe optional content |
| `WorkflowUtils.getFormElementValue()` | Extracts string value from any form element without explicit cast |
| Generate tokenized download URL | `ws.node.generateDownloadToken(uuid, false, false, null)` → append to `download?DTK=` |
| Send email programmatically | `ws.mail.sendMail(from, List<String> to, subject, htmlBody)` |

---

## Global Library — Reusable utility library (`Global library.okmflow`)

**File:** `template-global-library/Global library.okmflow`

> This is not a training example — it is a workflow template that provides a shared utility library. Deploy `template-global-library/Global library.okmflow` to your OpenKM instance once so any workflow can consume its nodes via Library nodes. It is documented here because other examples reference it. Read the "Library Node" section of the main reference document for the mechanics of how Library nodes work.

### What a library workflow is

A library workflow is a regular `.okmflow` file whose **Action nodes are called individually** by other workflows via a Library node — the workflow is never executed end-to-end. Its Start node has no outgoing transitions. Each Action node in it defines a reusable unit of code that other workflows can load by name.

The calling workflow uses a **Library node** configured with:
- The name of the library workflow (e.g., `Global library`).
- The name of the specific Action node to load (e.g., `library_global_base`).
- A variable name to bind the result in the current context.

The Action node script must end with `return <something>` — either a class (`return BaseLibrary`) or the script itself (`return this`). The caller receives that value and invokes methods on it.

### Library node naming convention

Library Action nodes in this project follow the naming convention `library_<scope>_<purpose>` (all lowercase, underscores). This distinguishes them visually from regular Action nodes in the designer.

---

### Library node: `library_global_base`

Defines a Groovy class `BaseLibrary` with static utility methods. The script ends with `return BaseLibrary`, so the caller receives the class and invokes methods as `BaseLibrary.methodName(...)`.

**Full script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.util.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import java.time.LocalDateTime;
import java.time.Duration;

class BaseLibrary {

    static void logInfo(String component, String message) {
        FileLogger.info(component, message);
    }

    static void logError(String component, String message, Exception e = null) {
        FileLogger.error(component, message + "\n" + StackTraceUtils.toString(e));
    }

    static OKMWebservices getWebservices() {
        return WebservicesHelper.getInstance();
    }

    static Object getContextValue(Map<String,Object> context, String key) {
        return context.get(key);
    }

    static void setContextValue(Map<String,Object> context, String key, Object value) {
        context.put(key, value);
    }

    static String getInitiatorId(Map<String,Object> context) {
        Actor actor = context.get("initiator");
        return actor.getId();
    }

    static long getProcessInstanceId(Map<String,Object> context) {
        def procIns = context.get("processInstance");
        return procIns.getId();
    }

    static String convertToJson(Object obj) {
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            return objectMapper.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            logError("BaseLibrary", "Error converting object to JSON", e);
            throw e;
        }
    }

    static <T> T convertFromJson(String jsonString, Class<T> targetClass) {
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
            return objectMapper.readValue(jsonString, targetClass);
        } catch (JsonProcessingException e) {
            logError("BaseLibrary", "Error converting JSON to object of type: " + targetClass.getSimpleName(), e);
            throw e;
        } catch (IllegalArgumentException e) {
            logError("BaseLibrary", "Invalid JSON string or target class", e);
            throw e;
        }
    }

    static boolean hasDurationElapsed(String durationString, LocalDateTime startTime) {
        try {
            Duration expectedDuration = Duration.parse(durationString);
            LocalDateTime currentTime = LocalDateTime.now();
            Duration elapsedDuration = Duration.between(startTime, currentTime);
            return elapsedDuration.compareTo(expectedDuration) >= 0;
        } catch (Exception e) {
            logError("BaseLibrary", e.getMessage(), e);
            throw e;
        }
    }
}

return BaseLibrary
```

**Method reference:**

| Method | Signature | Description |
|--------|-----------|-------------|
| `logInfo` | `(String component, String message)` | Delegates to `FileLogger.info()` |
| `logError` | `(String component, String message, Exception e = null)` | Logs error + stack trace via `FileLogger.error()` + `StackTraceUtils.toString()`. `e` is optional (defaults to `null`) |
| `getWebservices` | `()` → `OKMWebservices` | Returns `WebservicesHelper.getInstance()` |
| `getContextValue` | `(context, String key)` → `Object` | Reads a value from the workflow context |
| `setContextValue` | `(context, String key, Object value)` | Writes a value to the workflow context |
| `getInitiatorId` | `(context)` → `String` | Returns the user ID of the workflow initiator |
| `getProcessInstanceId` | `(context)` → `long` | Returns the process instance ID from the `processInstance` context variable |
| `convertToJson` | `(Object obj)` → `String` | Serializes any object to a JSON string using Jackson `ObjectMapper` |
| `convertFromJson` | `(String json, Class<T> targetClass)` → `T` | Deserializes a JSON string to the given class. Uses `FAIL_ON_UNKNOWN_PROPERTIES=false` to tolerate extra JSON fields |
| `hasDurationElapsed` | `(String durationString, LocalDateTime startTime)` → `boolean` | Returns true if the elapsed time since `startTime` is ≥ the ISO 8601 duration string (e.g., `"PT2H"` for 2 hours, `"P1D"` for 1 day) |

**Key points:**
- The `processInstance` context variable (type `ProcessInstance`) is populated automatically by the engine alongside `initiator` and `node`. Call `.getId()` to get the numeric workflow instance ID.
- `convertFromJson` with `FAIL_ON_UNKNOWN_PROPERTIES=false` allows deserializing to a POJO even when the JSON contains additional fields not declared in the target class — essential for forward-compatibility.
- `hasDurationElapsed` accepts ISO 8601 duration strings (`Duration.parse()`): `"PT30M"` (30 min), `"PT2H"` (2 h), `"P1D"` (1 day), `"P1DT12H"` (36 h). Used in Scheduled node workflows to check whether a waiting period has expired.
- Groovy allows optional parameters with default values: `Exception e = null` means callers can call `logError(comp, msg)` without passing an exception.

**How to call this library from another workflow (Library node configuration):**
```
Library workflow: Global library
Library node:     library_global_base
Bind variable:    BaseLibrary
```
After the Library node executes, the caller's context contains `BaseLibrary` bound to the class. Usage:
```groovy
def BaseLibrary = context.get("BaseLibrary");
BaseLibrary.logInfo("my-workflow", "Starting...");
long pid = BaseLibrary.getProcessInstanceId(context);
String json = BaseLibrary.convertToJson(myObject);
```

---

### Library node: `library_global_document`

Defines document-related utility functions as a Groovy script (not a class). Ends with `return this`, so the caller receives the script object and calls methods with `lib.methodName(...)`.

This node also shows how a library node can depend on another library: it declares `def BaseLibrary = BaseLibrary` to indicate that `BaseLibrary` must be bound in the context before this library is loaded (i.e., the caller must load `library_global_base` first).

**Full script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.util.*;
import java.util.*;

// Declare binding: BaseLibrary must already be in context when this library is loaded
def BaseLibrary = BaseLibrary

def rename(Map<String, String> context, String newName) {
    BaseLibrary.logInfo("GlobalDocumentLibrary", "rename");
    String uuid = BaseLibrary.getContextValue(context, "uuid");
    OKMWebservices ws = BaseLibrary.getWebservices();
    String path = ws.repository.getNodePath(uuid);
    String docName = PathUtils.getName(path);
    String fileName = FileUtils.getFileName(docName);
    int dashIndex = fileName.indexOf("-");
    fileName = (dashIndex >= 0) ? fileName.substring(0, dashIndex) : fileName;
    String fileExtension = FileUtils.getFileExtension(docName);
    newName = fileName + "-" + newName + "." + fileExtension;
    ws.document.rename(uuid, newName);
}

return this;
```

**Method reference:**

| Method | Signature | Description |
|--------|-----------|-------------|
| `rename` | `(context, String newName)` | Renames the workflow document. Takes the current filename up to the first `-`, appends `-newName`, preserves the extension. Example: `invoice-draft.pdf` + `"final"` → `invoice-final.pdf` |

**Key points:**
- `return this` (script-level return) — the caller receives the script binding object and calls methods as instance methods: `docLib.rename(context, "approved")`.
- `def BaseLibrary = BaseLibrary` — declares that the name `BaseLibrary` in this script is resolved from the Groovy binding (i.e., the context where this script is evaluated). The caller must have loaded `library_global_base` before loading this library, so that `BaseLibrary` is already in scope.
- `ws.repository.getNodePath(uuid)` — resolves a UUID to its repository path. Complement to `ws.repository.getNodeUuid(path)`.
- The rename logic: strip everything from the first `-` onwards in the filename, then rebuild as `prefix-newName.ext`. This creates a consistent naming convention across the workflow lifecycle (e.g., `doc-pending.pdf` → `doc-approved.pdf`).

**How to call this library from another workflow:**

The caller must load `library_global_base` first (so `BaseLibrary` is in context), then load `library_global_document`:
```
Library node 1: Global library / library_global_base  → bind to "BaseLibrary"
Library node 2: Global library / library_global_document → bind to "docLib"
```
Usage in an Action node after both Library nodes:
```groovy
def docLib = context.get("docLib");
docLib.rename(context, "approved");
```

---

### Summary of patterns illustrated

| Pattern | Where applied |
|---------|---------------|
| Library workflow structure | Start node with no transitions; Action nodes are loaded individually, never executed in sequence |
| Library node naming convention | `library_<scope>_<purpose>` (e.g., `library_global_base`) |
| Library returns a class | `return BaseLibrary` — caller uses `BaseLibrary.staticMethod()` |
| Library returns the script | `return this` — caller uses `lib.method()` as instance calls |
| Library-to-library dependency | `def BaseLibrary = BaseLibrary` in the dependent library's script — the parent library must be loaded first |
| `processInstance` context variable | `context.get("processInstance").getId()` — automatic engine variable, always available |
| JSON serialization with Jackson | `new ObjectMapper().writeValueAsString(obj)` |
| JSON deserialization with unknown fields | `objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false)` before `readValue()` |
| ISO 8601 duration check | `Duration.parse("PT2H")` + `Duration.between(start, now).compareTo(expected) >= 0` |
| Optional Groovy method parameter | `Exception e = null` — callers can omit the argument |
| `ws.repository.getNodePath(uuid)` | Resolves a UUID to its full repository path |
| Rename with prefix convention | Extract prefix before first `-`, rebuild as `prefix-newStatus.ext` |

---

## Example 09 — Library Management (Local and external library usage)

**Folder:** `09-library-management/`
**Process files:** `09-library-management/Library sample.okmflow` (main workflow) + `09-library-management/Global library.okmflow` (external library — identical to `template-global-library/Global library.okmflow`, included here to make the example self-contained)
**OpenKM metadata:** `09-library-management/metadata.xml`

### Description

Workflow that demonstrates the complete library pattern in OKMFlow: defining local library nodes (constants, metadata operations) within the workflow itself, consuming external library nodes from a separate library workflow (`Global library`), injecting dependencies between libraries, and placing all business logic inside **transition scripts** rather than dedicated Action nodes.

The workflow tracks a document through a lifecycle: draft → reviewed → final → archived, updating metadata and renaming the document at each step.

### Metadata group used (`metadata.xml`)

Group `okg:administrative`:
- `okp:administrative.owner` — input (user ID of the document owner).
- `okp:administrative.version` — input (version string).
- `okp:administrative.level` — select: `public`, `internal`, `restricted`, `confidential`.
- `okp:administrative.status` — select: `draft`, `reviewed`, `final`, `archived`.

### Node flow

```
Start ──[transition]──► [Task] Set reviewed ──[transition]──► [Task] Set final
      ──[transition]──► [Task] Set archived ──[transition]──► End

(all business logic lives in the transition scripts, not in separate Action nodes)
```

### Library nodes in this workflow (internal, `type: "action"`, no transitions)

Two Action nodes are defined aside from the main flow and serve as local library nodes — they have no outgoing transitions and are never executed sequentially:

- `library_constants` — constants class.
- `library_metadata` — metadata operations class (depends on `Constants` and `BaseLibrary`).

External library nodes (defined in `Global library.okmflow`, loaded by name):
- `library_global_base` — base utilities (see Global Library section).
- `library_global_document` — document rename utility (see Global Library section).

---

### `ScriptUtils` — the library loading API

`ScriptUtils` is the engine API for loading a library node by name. It searches across all deployed workflow processes for a node with the given name, so it works for both local nodes (same workflow) and external nodes (other workflows).

Two methods, used depending on what the library script returns:

| Method | Use when | Returns |
|--------|----------|---------|
| `ScriptUtils.evaluateFromNode("nodeName")` | The library script ends with `return SomeClass` | The class itself — call static methods directly |
| `ScriptUtils.parseFromNode("nodeName")` | The library script ends with `return this` or `return new Instance(...)` | A `Script` object — inject bindings, then call `.run()` |

**Standard loading block** (used identically in every transition script of this workflow):
```groovy
// Load constants (class-returning library)
Class Constants = ScriptUtils.evaluateFromNode("library_constants");

// Load external base library (class-returning)
Class BaseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

// Parse instance-returning libraries (do NOT run yet)
Script metlib    = ScriptUtils.parseFromNode("library_metadata");
Script docGloLib = ScriptUtils.parseFromNode("library_global_document");

// Inject dependencies into library_metadata
metlib.getBinding().setVariable("Constants",    Constants);
metlib.getBinding().setVariable("BaseLibrary",  BaseLibrary);

// Inject dependencies into library_global_document
docGloLib.getBinding().setVariable("BaseLibrary", BaseLibrary);

// Execute to obtain instances
def metadataLib      = metlib.run();
def documentGlobalLib = docGloLib.run();
```

**Key points:**
- `evaluateFromNode` runs the script immediately and returns the result. Use it for `return ClassName` libraries (no bindings needed, since static classes have no external dependencies).
- `parseFromNode` + binding injection + `run()` is the three-step pattern for libraries that declare `def X = X` dependencies at the top of their script. You must inject the dependencies as binding variables before calling `.run()`, otherwise the script throws an unresolved binding error.
- The loading block is repeated at the top of every transition script and every `assignExpr` that needs libraries — there is no shared state between script executions.
- `ScriptUtils` can also be used inside `assignExpr` (see Task nodes below).

---

### Local library node: `library_constants`

Defines a class with only `static final` string constants — metadata field names, group names, and the log filename. Returning a class lets callers reference constants as `Constants.FIELD_NAME` without instantiating anything.

```groovy
class Constants {
    static final String GENERAL_WORKFLOW_INITIATOR      = "initiator"
    static final String GENERAL_WORKFLOW_UUID           = "uuid"
    static final String LOG_FILENAME                    = "library-sample"

    static final String ADMINISTRATIVE_METADATA_GROUP_NAME  = "okg:administrative"
    static final String ADMINISTRATIVE_METADATA_FIELD_OWNER  = "okp:administrative.owner"
    static final String ADMINISTRATIVE_METADATA_FIELD_VERSION = "okp:administrative.version"
    static final String ADMINISTRATIVE_METADATA_FIELD_LEVEL   = "okp:administrative.level"
    static final String ADMINISTRATIVE_METADATA_FIELD_STATUS  = "okp:administrative.status"
}

return Constants
```

**Key point:** centralizing all string literals in a constants library prevents typos in field names and makes renaming a metadata field a one-file change. Reference values as `Constants.ADMINISTRATIVE_METADATA_FIELD_STATUS`.

---

### Local library node: `library_metadata`

Defines a class `MetadataLibrary` that encapsulates all operations on the `okg:administrative` metadata group. Dependencies (`BaseLibrary`, `Constants`) are injected via the Groovy binding before the script runs, then passed to the class constructor.

```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
// ... standard imports ...

// Declare binding variables (will be injected by caller before .run())
def BaseLibrary = BaseLibrary
def Constants   = Constants

class MetadataLibrary {

    def baseLibrary
    def constants

    MetadataLibrary(baseLibrary, constants) {
        this.baseLibrary = baseLibrary
        this.constants   = constants
    }

    void addMetadata(Map<String,Object> context, Map<String,String> properties) {
        baseLibrary.logInfo("MetadataLibrary", "addMetadata");
        OKMWebservices ws = baseLibrary.getWebservices();
        String uuid = baseLibrary.getContextValue(context, constants.GENERAL_WORKFLOW_UUID);
        ws.propertyGroup.addGroup(uuid, constants.ADMINISTRATIVE_METADATA_GROUP_NAME, properties);
    }

    Map<String,String> getMetadata(Map<String,Object> context) {
        baseLibrary.logInfo("MetadataLibrary", "getMetadata");
        OKMWebservices ws = baseLibrary.getWebservices();
        String uuid = baseLibrary.getContextValue(context, constants.GENERAL_WORKFLOW_UUID);
        return ws.propertyGroup.getProperties(uuid, constants.ADMINISTRATIVE_METADATA_GROUP_NAME);
    }

    void updateMetadata(Map<String,Object> context, Map<String,String> properties) {
        baseLibrary.logInfo("MetadataLibrary", "updateMetadata");
        OKMWebservices ws = baseLibrary.getWebservices();
        String uuid = baseLibrary.getContextValue(context, constants.GENERAL_WORKFLOW_UUID);
        ws.propertyGroup.setProperties(uuid, constants.ADMINISTRATIVE_METADATA_GROUP_NAME, properties);
    }

    Map<String,String> createAdministrativeMetadata(String owner, String version,
                                                     String level, String status) {
        return [
            (constants.ADMINISTRATIVE_METADATA_FIELD_OWNER):   owner,
            (constants.ADMINISTRATIVE_METADATA_FIELD_VERSION):  version,
            (constants.ADMINISTRATIVE_METADATA_FIELD_LEVEL):    level,
            (constants.ADMINISTRATIVE_METADATA_FIELD_STATUS):   status
        ];
    }
}

// Return an instance with dependencies injected via constructor
return new MetadataLibrary(BaseLibrary, Constants)
```

**Key points:**
- `def BaseLibrary = BaseLibrary` at the top of the script (before the class definition) declares that `BaseLibrary` will be resolved from the Groovy binding. The caller injects it with `metlib.getBinding().setVariable("BaseLibrary", BaseLibrary)` before `.run()`.
- The class stores injected dependencies as instance variables (`this.baseLibrary`, `this.constants`) so all methods can use them without receiving them as parameters on every call.
- `return new MetadataLibrary(BaseLibrary, Constants)` — returns a ready-to-use instance, not the class. The caller receives an object and calls instance methods: `metadataLib.getMetadata(context)`.
- `(constants.FIELD_KEY): value` — Groovy map literal with a dynamic key (the parentheses evaluate the expression as the key).

**Method reference:**

| Method | Signature | Description |
|--------|-----------|-------------|
| `addMetadata` | `(context, properties)` | Calls `ws.propertyGroup.addGroup()` |
| `getMetadata` | `(context)` → `Map<String,String>` | Calls `ws.propertyGroup.getProperties()` |
| `updateMetadata` | `(context, properties)` | Calls `ws.propertyGroup.setProperties()` |
| `createAdministrativeMetadata` | `(owner, version, level, status)` → `Map<String,String>` | Builds the properties map using constant field names |

---

### Node: Start

Standard start node. Has a **named transition** `"add metadata"` with a transition script that does all the initial work.

**Transition script — "add metadata":**
```groovy
// (standard loading block — see above)

try {
    BaseLibrary.logInfo(Constants.LOG_FILENAME, ">> start transition add metadata");

    // Create initial metadata for the document
    String userId = BaseLibrary.getInitiatorId(context);
    Map<String,String> properties = metadataLib.createAdministrativeMetadata(
        userId,           // owner = initiator's user ID
        "1",              // version
        "[\"confidential\"]",  // level (JSON array format)
        "[\"draft\"]"          // status
    );
    metadataLib.addMetadata(context, properties);

    // Rename document to reflect current status
    documentGlobalLib.rename(context, "draft");

} catch (Exception e) {
    BaseLibrary.logError(Constants.LOG_FILENAME, "Error in workflow execution", e);
    throw e;
} finally {
    BaseLibrary.logInfo(Constants.LOG_FILENAME, "<< end transition add metadata");
}
```

**Key point:** wrapping the transition script body in `try/catch/finally` is a recommended pattern — the `catch` logs the error and re-throws so the engine sees the failure, and the `finally` always logs the exit boundary regardless of success or failure.

**Transition target:** Task "Set reviewed".

---

### Node: Task — "Set reviewed"

**Assignment (`assignExpr`):**
```groovy
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");
String userId = baseLibrary.getInitiatorId(context);
return userId;
```

The library is loaded directly inside `assignExpr` — `ScriptUtils` works in any Groovy context within the workflow engine.

**Form:**
```xml
<workflow-form>
  <button label="Mark as reviewed" name="reviewed" style="yes" color="success" />
</workflow-form>
```

A form with a single button and **no `transition` attribute** on the button. When there is only one outgoing transition from the task, the engine follows it automatically on any form submission — no need to specify the transition name.

**Transition script — "set reviewed metadata":**
```groovy
// (standard loading block)
try {
    Map<String,String> properties = metadataLib.getMetadata(context);
    properties.put(Constants.ADMINISTRATIVE_METADATA_FIELD_STATUS, "[\"reviewed\"]");
    metadataLib.updateMetadata(context, properties);
    documentGlobalLib.rename(context, "reviewed");
} catch (Exception e) {
    BaseLibrary.logError(Constants.LOG_FILENAME, "Error in workflow execution", e);
    throw e;
} finally {
    BaseLibrary.logInfo(Constants.LOG_FILENAME, "<< end transition set reviewed metadata");
}
```

**Transition target:** Task "Set final".

---

### Node: Task — "Set final"

Identical structure to "Set reviewed". Button label: "Mark as final". Transition script updates status to `"final"` and renames with suffix `"final"`. Target: Task "Set archived".

### Node: Task — "Set archived"

Identical structure. Button label: "Mark as archived". Transition script updates status to `"archived"` and renames with suffix `"archived"`. Target: End.

---

### Node: End

Standard end node.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| `ScriptUtils.evaluateFromNode("name")` | Load a class-returning library; result is the class — call static methods directly |
| `ScriptUtils.parseFromNode("name")` | Load an instance-returning library; returns a `Script` — inject bindings, then call `.run()` |
| Binding injection before `.run()` | `script.getBinding().setVariable("Key", value)` — inject dependencies into a library that declares `def X = X` |
| `ScriptUtils` in `assignExpr` | Library nodes can be loaded from task assignment expressions, not only from action/transition scripts |
| Constants library pattern | Class with only `static final` fields, `return ClassName` — centralizes all string literals |
| Constructor injection in library class | `MetadataLibrary(baseLibrary, constants)` stores deps as instance vars; `return new MetadataLibrary(...)` |
| Dynamic Groovy map key | `(constants.FIELD_NAME): value` — parentheses evaluate the expression as the map key |
| Business logic in transition scripts | No dedicated Action nodes — all work happens in the transition script that connects two nodes |
| `try/catch/finally` in scripts | Log entry/exit with `finally`, log+rethrow errors with `catch` |
| Button without `transition` attribute | A single-button form with one outgoing transition — engine follows it automatically |
| Internal vs external library nodes | Internal: defined in the same workflow file (no prefix required). External: defined in a separate library workflow (`Global library`), loaded by the same `ScriptUtils` API |

---

## Example 10 — External Business Logic (Delegating to a server-side REST plugin)

**Folder:** `10-Using external bussiness logic/`
**Process file:** `10-Using external bussiness logic/External logic sample.okmflow`
**Server plugin:** `project/rest-plugin-library/` (Java project deployed inside OpenKM)
**REST client test:** `project/rest-client-sample/` (standalone Java client to verify the plugin)

### Description

Workflow that demonstrates how to delegate complex server-side logic to a **REST plugin** deployed inside OpenKM, instead of implementing it directly in Groovy. The workflow library (`library_metadata`) calls the plugin via `ws.plugin.executePluginGet()`. The plugin receives the request, deserializes the JSON payload, and uses OpenKM's internal Java API (`DbPropertyGroupModule`, `DbDocumentModule`) to write metadata and rename the document.

The node structure is identical to Example 09 (Start → Set reviewed → Set final → Set archived → End, all logic in transition scripts). The key difference is the mechanism used to apply metadata: instead of calling `ws.propertyGroup` directly, the library delegates to a named plugin.

### Architecture

```
Workflow Groovy script
  └─ library_metadata.addOrUpdateMetadata(context, metadata)
       └─ ws.plugin.executePluginGet("com.openkm.plugin.rest.AddOrUpdateMetadataRestPlugin",
                                      parameters, String.class)
            └─ [Server] AddOrUpdateMetadataRestPlugin.executePlugin(parameters, is)
                 ├─ URLDecoder.decode(json) → Gson().fromJson() → AdministrativeMetadata
                 ├─ dbPropertyGroupModule.addGroup() / setProperties()
                 └─ dbDocumentModule.rename()
```

---

### Server-side: Java bean (`AdministrativeMetadata.java`)

The Java version of the metadata bean is defined in the plugin project and deployed with it. It is the authoritative definition; the Groovy version in the workflow library mirrors it.

```java
public class AdministrativeMetadata implements Serializable {
    // Property group and field name constants
    public static final String PROPERTY_GROUP_NAME = "okg:administrative";
    public static final String OWNER_FIELD   = "okp:administrative.owner";
    public static final String VERSION_FIELD = "okp:administrative.version";
    public static final String LEVEL_FIELD   = "okp:administrative.level";
    public static final String STATUS_FIELD  = "okp:administrative.status";

    // Fields — select values stored as List<String>
    private String owner;
    private String version;
    private List<String> level  = new ArrayList<>();
    private List<String> status = new ArrayList<>();

    // Constructors, getters/setters, equals/hashCode/toString ...
}
```

**Key points:**
- Select-type metadata fields (`level`, `status`) use `List<String>` because OpenKM stores them as JSON arrays. The plugin converts them back with `new Gson().toJson(list)` before writing to the property group.
- The class implements `Serializable` — required for the JSON round-trip between the workflow and the plugin.
- Field name constants are declared on the Java class (`OWNER_FIELD`, etc.) — the server plugin uses them directly.

---

### Server-side: REST plugin (`AddOrUpdateMetadataRestPlugin.java`)

```java
@PluginImplementation
public class AddOrUpdateMetadataRestPlugin extends BasePlugin implements RestPlugin {

    @Autowired private DbPropertyGroupModule dbPropertyGroupModule;
    @Autowired private DbRepositoryModule    dbRepositoryModule;
    @Autowired private DbDocumentModule      dbDocumentModule;

    @Override
    public Object executePlugin(Map<String, String> parameters, InputStream is) throws Exception {
        String uuid = parameters.get("uuid");

        if (parameters.containsKey(AdministrativeMetadata.class.getSimpleName())) {
            // Decode and deserialize the JSON payload
            String jsonString = parameters.get(AdministrativeMetadata.class.getSimpleName());
            jsonString = URLDecoder.decode(jsonString, StandardCharsets.UTF_8);
            AdministrativeMetadata metadata = new Gson().fromJson(jsonString, AdministrativeMetadata.class);

            // Build the property map
            Map<String, String> properties = new HashMap<>();
            properties.put(AdministrativeMetadata.OWNER_FIELD,   metadata.getOwner());
            properties.put(AdministrativeMetadata.VERSION_FIELD, metadata.getVersion());
            properties.put(AdministrativeMetadata.LEVEL_FIELD,   new Gson().toJson(metadata.getLevel()));
            properties.put(AdministrativeMetadata.STATUS_FIELD,  new Gson().toJson(metadata.getStatus()));

            // Add or update the property group
            if (!dbPropertyGroupModule.hasGroup(null, uuid, AdministrativeMetadata.PROPERTY_GROUP_NAME)) {
                dbPropertyGroupModule.addGroup(null, uuid, AdministrativeMetadata.PROPERTY_GROUP_NAME, properties);
            } else {
                dbPropertyGroupModule.setProperties(null, uuid, AdministrativeMetadata.PROPERTY_GROUP_NAME, properties);
            }

            // Rename document: prefix-status.ext
            if (!metadata.getStatus().isEmpty()) {
                String path    = dbRepositoryModule.getNodePath(null, uuid);
                String docName = PathUtils.getName(path);
                String fileName = FileUtils.getFileName(docName);
                int dashIndex = fileName.indexOf("-");
                fileName = (dashIndex >= 0) ? fileName.substring(0, dashIndex) : fileName;
                String newName = fileName + "-" + metadata.getStatus().get(0)
                               + "." + FileUtils.getFileExtension(docName);
                dbDocumentModule.rename(null, uuid, newName);
            }

            return "ok";
        }
        return "void";
    }
}
```

**Key points:**
- `@PluginImplementation` — annotation required by the OpenKM plugin framework for auto-discovery.
- `extends BasePlugin implements RestPlugin` — the two required parents for a plugin callable via `ws.plugin.executePlugin*()`.
- `@Autowired` fields — the plugin runs inside the OpenKM Spring context, so it has full access to internal modules (`DbPropertyGroupModule`, `DbDocumentModule`, `DbRepositoryModule`). These use the **internal Java API**, not the REST SDK.
- `null` as first parameter to internal module methods — the first argument is the `Token` (authentication token). Passing `null` means "run as system" (the plugin's own identity).
- The JSON parameter is **URL-decoded** on arrival — the workflow side URL-encodes it before sending (see below).
- The parameter key convention is `AdministrativeMetadata.class.getSimpleName()` → `"AdministrativeMetadata"`. Using the class simple name avoids naming collisions and makes the payload self-describing.
- The plugin converts `List<String>` back to a JSON string (`new Gson().toJson(list)`) before storing in the property group — because OpenKM metadata stores list values as JSON arrays.

---

### REST client test (`RestClientSample.java`)

A standalone Java main class that validates the plugin end-to-end without a workflow. Shows the calling pattern from an external client:

```java
OKMWebservices ws = OKMWebservicesFactory.getInstance(host);
ws.login(user, password);

AdministrativeMetadata metadata = new AdministrativeMetadata(
    "okmAdmin", "1.0", Arrays.asList("public"), Arrays.asList("draft"));
String metadataJsonValue = new Gson().toJson(metadata);
metadataJsonValue = URLEncoder.encode(metadataJsonValue, "UTF-8");

Map<String, String> parameters = new HashMap<>();
parameters.put("uuid", uuid);
parameters.put(AdministrativeMetadata.class.getSimpleName(), metadataJsonValue);

String result = (String) ws.plugin.executePluginPost(
    "com.openkm.plugin.rest.AddOrUpdateMetadataRestPlugin",
    parameters, String.class, null);
```

**Note:** the REST client uses `executePluginPost` (with an `InputStream` payload slot); the workflow library uses `executePluginGet`. Both call the same plugin — the method variant determines how the HTTP request is constructed, but the plugin logic is identical.

---

### Local library node: `library_constants`

Extends the Constants class from Example 09 with one additional constant: the context key where the metadata object is persisted as a JSON string between workflow steps.

```groovy
class Constants {
    // ... (same as Example 09) ...
    static final String CONTEXT_ADMINISTRATIVE_METADATA = "AdministrativeMetadata"
}
return Constants
```

---

### Local library node: `library_administrative_metadata`

Groovy mirror of the Java `AdministrativeMetadata` bean. Defined as a library node so that workflow scripts can instantiate it with `AdministrativeMetadata.newInstance(...)` and serialize/deserialize it with `BaseLibrary.convertToJson()` / `convertFromJson()`. Must be JSON-compatible with the server-side Java bean.

Notable differences from the Java version:
- Has an additional constructor accepting plain strings (`String level, String status`) that wraps them into single-element lists — convenient for calling with literal values.
- Has `setSingleStatus(String)` and `setSingleLevel(String)` methods that clear and re-add a single value — used in transition scripts to update the status field without replacing the entire list structure.
- Returns the class: `return AdministrativeMetadata`.

Usage in transition scripts:
```groovy
Class AdministrativeMetadata = ScriptUtils.evaluateFromNode("library_administrative_metadata");

// Create instance using Groovy's newInstance() on a class reference
def metadata = AdministrativeMetadata.newInstance(userId, "1.0", "internal", "draft");

// Update a single field
metadata.setSingleStatus("reviewed");

// Serialize to JSON for context storage
String json = BaseLibrary.convertToJson(metadata);
BaseLibrary.setContextValue(context, Constants.CONTEXT_ADMINISTRATIVE_METADATA, json);

// Deserialize from context
String json = BaseLibrary.getContextValue(context, Constants.CONTEXT_ADMINISTRATIVE_METADATA);
def metadata = BaseLibrary.convertFromJson(json, AdministrativeMetadata);
```

**Key point:** `AdministrativeMetadata.newInstance(args...)` — Groovy's way of calling `new ClassName(args)` when you have a class reference (not a literal class name). This is necessary because `new AdministrativeMetadata(...)` does not work when `AdministrativeMetadata` is a runtime variable holding a class.

---

### Local library node: `library_metadata`

Defines `MetadataLibrary`, a class with one method that calls the REST plugin:

```groovy
def BaseLibrary = BaseLibrary
def Constants = Constants
def AdministrativeMetadata = AdministrativeMetadata  // injected via binding

class MetadataLibrary {
    def baseLibrary, constants, administrativeMetadata

    MetadataLibrary(baseLibrary, constants, administrativeMetadata) { ... }

    void addOrUpdateMetadata(Map<String,Object> context, def administrativeMetadata) {
        OKMWebservices ws = baseLibrary.getWebservices();
        String uuid = baseLibrary.getContextValue(context, "uuid");

        // Serialize the bean to JSON and URL-encode it
        String jsonStringValue = baseLibrary.convertToJson(administrativeMetadata);
        jsonStringValue = URLEncoder.encode(jsonStringValue, StandardCharsets.UTF_8);

        // Escape % for FileLogger (printf-style formatting)
        String safeLogValue = jsonStringValue.replace("%", "%%");
        baseLibrary.logInfo(constants.LOG_FILENAME, "encoded: " + safeLogValue);

        // Build parameters and call the plugin
        Map<String, String> parameters = new HashMap();
        parameters.put("uuid", uuid);
        parameters.put("AdministrativeMetadata", jsonStringValue);

        String result = ws.plugin.executePluginGet(
            "com.openkm.plugin.rest.AddOrUpdateMetadataRestPlugin",
            parameters, String.class);

        baseLibrary.logInfo(constants.LOG_FILENAME, "result: " + result);
    }
}

return new MetadataLibrary(BaseLibrary, Constants, AdministrativeMetadata)
```

**Key points:**
- `ws.plugin.executePluginGet(className, parameters, returnType)` — invokes a server-side plugin by its **fully qualified class name**. Parameters are passed as `Map<String, String>`. The return type class is used for casting the result.
- JSON values in the parameter map **must be URL-encoded** before sending — the plugin URL-decodes them on arrival. Without encoding, characters like `{`, `"`, `:` would break the HTTP parameter parsing.
- `jsonStringValue.replace("%", "%%")` — `FileLogger` uses printf-style format strings. A literal `%` in a URL-encoded string would be interpreted as a format specifier and cause a `MissingFormatArgumentException`. Escape before logging.
- The `def` type declaration on the method parameter (`def administrativeMetadata`) — required in Groovy when the parameter type is a class loaded at runtime (not a statically known type). Using `Object` also works.

---

### Transition script pattern (all three transitions: "set reviewed/final/archived metadata")

```groovy
// Standard loading block
Class Constants              = ScriptUtils.evaluateFromNode("library_constants");
Class BaseLibrary            = ScriptUtils.evaluateFromNode("library_global_base");
Class AdministrativeMetadata = ScriptUtils.evaluateFromNode("library_administrative_metadata");
Script metlib = ScriptUtils.parseFromNode("library_metadata");
metlib.getBinding().setVariable("BaseLibrary",            BaseLibrary);
metlib.getBinding().setVariable("Constants",              Constants);
metlib.getBinding().setVariable("AdministrativeMetadata", AdministrativeMetadata);
def metadataLib = metlib.run();

try {
    // Recover the metadata object from context (stored as JSON string)
    String json = BaseLibrary.getContextValue(context, Constants.CONTEXT_ADMINISTRATIVE_METADATA);
    def metadata = BaseLibrary.convertFromJson(json, AdministrativeMetadata);

    // Update the status field
    metadata.setSingleStatus("reviewed"); // or "final", "archived"

    // Delegate to the server plugin
    metadataLib.addOrUpdateMetadata(context, metadata);

    // Persist updated object back to context as JSON
    BaseLibrary.setContextValue(context,
        Constants.CONTEXT_ADMINISTRATIVE_METADATA,
        BaseLibrary.convertToJson(metadata));

} catch (Exception e) {
    BaseLibrary.logError(Constants.LOG_FILENAME, "Error in workflow execution", e);
    throw e;
} finally {
    BaseLibrary.logInfo(Constants.LOG_FILENAME, "<< end transition ...");
}
```

**Context serialization pattern:** the `AdministrativeMetadata` object cannot be stored directly in the workflow context between steps (context values are serialized). The solution is to convert it to a JSON string with `convertToJson()` and store that string in context. On the next step, recover the string with `getContextValue()` and rebuild the object with `convertFromJson()`.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| `ws.plugin.executePluginGet(className, parameters, returnType)` | Call a deployed server-side plugin by fully qualified class name |
| JSON payload URL-encoding for plugin parameters | `URLEncoder.encode(json, UTF_8)` before `parameters.put()`; plugin does `URLDecoder.decode()` on arrival |
| `%` escaping before `FileLogger` | `.replace("%", "%%")` on URL-encoded strings to avoid printf format errors |
| Bean as library node (Groovy mirror of Java bean) | `library_administrative_metadata` defines the DTO in Groovy; JSON-compatible with the server Java bean |
| `ClassName.newInstance(args)` | Groovy way to call `new ClassName(args)` when `ClassName` is a runtime class reference variable |
| `setSingleStatus()` / `setSingleLevel()` | Utility methods that clear and re-set a single-value List field — avoids stale accumulated values |
| Context serialization via JSON | Store complex objects as JSON strings in context; recover and deserialize on the next step |
| Server plugin structure | `@PluginImplementation`, `extends BasePlugin implements RestPlugin`, `@Autowired` internal modules, `null` token = system identity |
| Internal module API (server side) | `dbPropertyGroupModule`, `dbDocumentModule`, `dbRepositoryModule` — direct Java API, first param is `null` (system token) |
| `executePluginGet` vs `executePluginPost` | GET: parameters only. POST: parameters + InputStream body. Same plugin handles both |
| Plugin parameter key = class simple name | `parameters.put(AdministrativeMetadata.class.getSimpleName(), json)` — self-describing, avoids naming conflicts |

---

## Example 11 — Cron Jobs and Scheduled Tasks

**Folder:** `11-Cron jobs and scheduled task/`
**Diagram:** `11-Cron jobs and scheduled task/diagram.png`
**Process file:** `11-Cron jobs and scheduled task/Cron jobs.okmflow`
**External library:** `11-Cron jobs and scheduled task/Global library.okmflow` (same as `template-global-library/Global library.okmflow`)

### Description

Workflow that demonstrates two complementary deadline-enforcement mechanisms: **task-level `dueDate`/`repeat`** (built-in engine reminders sent to the assignee when a task is overdue) and the **`scheduled_job` node** (a custom cron that runs periodically across all active instances of the workflow and can execute any Groovy logic — here, sending an escalation email to a supervisor when a task exceeds a time threshold).

### How the scheduler works

The engine runs a global timer whose interval is configured in `application.properties`:
```
scheduled.actions.rate=PT60S
```
On every tick, the engine finds **all active workflow instances** that contain a node named exactly `scheduled_job` and executes that node's script for each instance. This means a single `scheduled_job` node handles all concurrent instances of the workflow — the script uses the `processInstance` context variable to know which instance it is currently inspecting.

### Node flow

```
Start → [Task] Assign supervisor → [Task] Supervised task → End

(library_constants and scheduled_job are detached nodes — no transitions)
```

---

### Local library node: `library_constants`

```groovy
class Constants {
    static final String LOG_FILENAME          = "cron-job"
    static final String TASK_ASSIGN_SUPERVISOR = "Assign supervisor"
    static final String TASK_SUPERVISED_TASK   = "Supervised task"
}
return Constants
```

Task name constants are used in the `scheduled_job` script to identify the currently active task without hardcoding strings.

---

### Node: Task — "Assign supervisor"

**Assignment:** `library_global_base.getInitiatorId(context)` → initiator.

The initiator enters the supervisor's email address. This task has a built-in deadline configured via `dueDate` and `repeat`.

**Form:**
```xml
<workflow-form>
  <input label="Supervisor email" name="email" />
</workflow-form>
```

**Deadline fields:**
```
dueDate: "PT20S"
repeat:  "PT2M"
```

**How `dueDate` / `repeat` work:**
- `dueDate` — ISO 8601 duration. After this period elapses from when the task was assigned, the engine sends a reminder notification to the task assignee (using `notificationSubject` / `notificationBody`).
- `repeat` — ISO 8601 duration. After the initial `dueDate` notification, the engine sends the same notification again every `repeat` interval until the task is completed.
- These are **built-in engine reminders directed at the assignee** — they do not execute custom Groovy code and do not advance the workflow. They are independent of the `scheduled_job` mechanism.

**Transition:** unnamed → "Supervised task".

---

### Node: Task — "Supervised task"

**Assignment:** initiator.

No `dueDate` or `repeat` — the deadline for this task is enforced externally by the `scheduled_job` node. The form has a single button to mark the task as done.

**Form:**
```xml
<workflow-form>
  <button label="Completed" name="completed" style="yes" color="success" />
</workflow-form>
```

**Transition:** unnamed → End.

---

### Node: Scheduled — `scheduled_job`

An Action node named exactly `scheduled_job`. No outgoing transitions. The engine executes its script on every global scheduler tick (`scheduled.actions.rate`) for every active workflow instance. The script inspects the current task of its process instance and acts accordingly.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.util.*;
import java.time.format.DateTimeFormatter;

Class Constants    = ScriptUtils.evaluateFromNode("library_constants");
Class baseLibrary  = ScriptUtils.evaluateFromNode("library_global_base");

FileLogger.info(Constants.LOG_FILENAME, ">>>Scheduled job running");

// Get the process instance and its current active task
def processInstance = baseLibrary.getContextValue(context, "processInstance");
def taskInstanceDTO = WorkflowUtils.getCurrentTaskInstance(processInstance.id);

if (taskInstanceDTO != null) {
    String taskName = taskInstanceDTO.getName();

    if (taskName.equals(Constants.TASK_ASSIGN_SUPERVISOR)) {
        // Task still waiting for email input — just log
        FileLogger.info(Constants.LOG_FILENAME, "Waiting to assign the email of the supervisor");

    } else if (taskName.equals(Constants.TASK_SUPERVISED_TASK)) {
        def taskStarted = taskInstanceDTO.getStart(); // LocalDateTime
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        FileLogger.info(Constants.LOG_FILENAME, "Task assigned at: " + taskStarted.format(formatter));

        // Check if the task has exceeded the allowed duration
        String dueDate = "PT4M";
        if (baseLibrary.hasDurationElapsed(dueDate, taskStarted)) {
            if (!context.containsKey("emailSent")) {
                // Send escalation email to the supervisor — only once
                String supervisorEmail = WorkflowUtils.getFormElementValue(context, "email");
                OKMWebservices ws = WebservicesHelper.getInstance();
                ws.mail.sendMail(
                    "noreply@nomail.com",
                    Arrays.asList(supervisorEmail),
                    "Overdue task that requires your attention",
                    "Task 'Supervised task' not completed within time limits " +
                    "by user " + taskInstanceDTO.getActor() +
                    " in process instance " + processInstance.id + "."
                );
                // Mark as sent to prevent duplicate emails on subsequent ticks
                baseLibrary.setContextValue(context, "emailSent", true);
            } else {
                FileLogger.info(Constants.LOG_FILENAME, "Overdue email already sent to the supervisor");
            }
        } else {
            FileLogger.info(Constants.LOG_FILENAME, "Still on time to be completed");
        }
    }
}

FileLogger.info(Constants.LOG_FILENAME, "<<<Scheduled job stopped");
```

**Key points:**

- `scheduled_job` is a **reserved node name** (like `run_config`). Any Action node with this exact name is picked up by the engine's scheduler and executed periodically.
- `WorkflowUtils.getCurrentTaskInstance(processInstanceId)` — returns a `TaskInstanceDTO` for the currently active (pending) task in the given process instance, or `null` if no task is active (e.g., the instance has ended).
- `TaskInstanceDTO.getName()` — the node name of the active task (e.g., `"Supervised task"`).
- `TaskInstanceDTO.getStart()` — a `LocalDateTime` of when the task was assigned to the user. Used as the reference point for elapsed-time calculations.
- `TaskInstanceDTO.getActor()` — the user ID currently assigned to the task.
- `baseLibrary.hasDurationElapsed("PT4M", taskStarted)` — `BaseLibrary` utility (from `Global library`) that computes whether `Duration.between(taskStarted, now) >= Duration.parse("PT4M")`.
- **One-shot flag pattern:** the scheduler fires repeatedly. To ensure an action (here, sending an email) happens only once: check `context.containsKey("key")` before acting, then call `context.put("key", true)` after. On subsequent ticks the key is present and the action is skipped.
- `WorkflowUtils.getFormElementValue(context, "email")` — reads the string value of the `email` input field written by the "Assign supervisor" task. The supervisor email entered in the first task is still available in context throughout the workflow lifetime.

---

### `dueDate` / `repeat` vs `scheduled_job` — comparison

| | `dueDate` / `repeat` on Task | `scheduled_job` node |
|---|---|---|
| **Who triggers it** | Engine built-in timer | Global scheduler (`scheduled.actions.rate`) |
| **What it does** | Sends the task's `notificationSubject`/`notificationBody` to the assignee | Executes any Groovy script |
| **Scope** | Per-task deadline reminder | Any logic: emails, metadata updates, REST calls… |
| **Recipient** | Task assignee | Whoever the script targets |
| **Configuration** | `dueDate` + `repeat` fields on the Task node (ISO 8601) | `scheduled.actions.rate` in `application.properties` |
| **Code required** | None | Full Groovy script |

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| `scheduled_job` naming convention | Action node named exactly `scheduled_job` — executed by the engine scheduler on every tick for each active instance |
| `scheduled.actions.rate` | ISO 8601 duration in `application.properties` — global scheduler interval for all `scheduled_job` nodes |
| `WorkflowUtils.getCurrentTaskInstance(processInstanceId)` | Returns `TaskInstanceDTO` for the active task of a process instance; `null` if none |
| `TaskInstanceDTO.getName()` | Current active task node name — used to branch behavior per task |
| `TaskInstanceDTO.getStart()` | `LocalDateTime` the task was assigned — elapsed-time reference point |
| `TaskInstanceDTO.getActor()` | User ID currently assigned to the active task |
| `baseLibrary.hasDurationElapsed(iso8601, startTime)` | ISO 8601 duration check from `Global library` — returns true when elapsed time ≥ threshold |
| One-shot flag in context | `context.containsKey("key")` before acting + `context.put("key", true)` after — prevents repeated execution across scheduler ticks |
| `dueDate` / `repeat` on Task node | Built-in engine deadline reminders to the assignee; ISO 8601 durations; no custom code |
| `dueDate` vs `repeat` | `dueDate` = first reminder delay; `repeat` = subsequent reminder interval after `dueDate` elapses |

---

## Example 12 — Parallel Workflows (parent-child)

> Complete reference pattern for orchestrating N child workflows in parallel from a parent workflow. The parent freezes at a task assigned to `system` and a scheduled node periodically checks whether all children have finished. When they have, the parent advances programmatically. Each child notifies the parent by writing a variable into its context.

### Overview

The example consists of two workflows:

- **Request-to-managers** (parent): manages an event request. Collects data from the initiator, reviewed by a coordinator, launches N child workflow instances (one per selected manager), waits for them to finish, and shows the aggregated result to the coordinator to approve or reject.
- **Manager-voting** (child): each manager completes a voting questionnaire, saves the result to a SQL table, and notifies the parent by writing a variable into its context before finishing.

Synchronisation between parent and child relies on four mechanisms:
1. The parent stores the manager list (`managersList`) and a counter (`actorNum`) in its own context.
2. The parent stores each child's process instance ID in a variable `pi_<manager>`.
3. The child, before ending, writes `status_<manager> = "ENDED"` into the parent's context via `WorkflowUtils.addProcessInstanceVariable`.
4. The parent's `scheduled_action` node checks that all `status_<manager>` variables exist; when the count equals `actorNum`, it programmatically advances the waiting task.

### Prerequisites

**Users:**
- `coordinator` — parent process reviewer
- `manager1`, `manager2` — approvers (child processes)

**SQL tables:**
```sql
CREATE TABLE WF_EVENT_VOTING (
  UUID           VARCHAR(36)  NOT NULL,
  USER_ID        VARCHAR(32)  NOT NULL,
  EP_VOTE        CHAR(1),
  EP_OBSERVATION LONGTEXT,
  EP_SPEAKER     VARCHAR(512),
  PRIMARY KEY (UUID, USER_ID)
);

CREATE TABLE WF_DEPENDING (
  DEP_ID   VARCHAR(128),
  DEP_NAME VARCHAR(128),
  DEP_TYPE VARCHAR(10),
  PRIMARY KEY (DEP_ID)
);

-- Sample event data for the form select
INSERT INTO WF_DEPENDING VALUES ('gti_mexico_opening', 'GTI México Opening', 'event');
INSERT INTO WF_DEPENDING VALUES ('hyundai_tech_day',   'Hyundai Tech Day',   'event');
INSERT INTO WF_DEPENDING VALUES ('supplier_events',    'Supplier Events',    'event');
INSERT INTO WF_DEPENDING VALUES ('others',             'Others',             'event');
```

**Metadata group** (`okg:event_management`): fields `request_name`, `event`, `starting_session`, `duration`, `location`, `comments`, `expected_benefits`, `deadline`, `status` (readonly).

### Global library — library_global_base

Shared library used by both workflows. Loaded with `ScriptUtils.evaluateFromNode("library_global_base")`.
Lives in the auxiliary workflow **Global library** (`Global library.okmflow`).

```groovy
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import java.time.LocalDateTime;
import java.time.Duration;

class BaseLibrary {

    static void logInfo(String component, String message) {
        FileLogger.info(component, message);
    }

    static void logError(String component, String message, Exception e = null) {
        FileLogger.error(component, message + "\n" + StackTraceUtils.toString(e));
    }

    static OKMWebservices getWebservices() {
        return WebservicesHelper.getInstance();
    }

    static Object getContextValue(Map<String,Object> context, String key) {
        return context.get(key);
    }

    static void setContextValue(Map<String,Object> context, String key, Object value) {
        context.put(key, value);
    }

    static String getInitiatorId(Map<String,Object> context) {
        Actor actor = context.get("initiator");
        return actor.getId();
    }

    static long getProcessInstanceId(Map<String,Object> context) {
        def procIns = context.get("processInstance");
        return procIns.getId();
    }

    static String convertToJson(Object obj) {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper.writeValueAsString(obj);
    }

    static <T> T convertFromJson(String jsonString, Class<T> targetClass) {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        return objectMapper.readValue(jsonString, targetClass);
    }

    static boolean hasDurationElapsed(String durationString, LocalDateTime startTime) {
        Duration expectedDuration = Duration.parse(durationString);
        Duration elapsedDuration  = Duration.between(startTime, LocalDateTime.now());
        return elapsedDuration.compareTo(expectedDuration) >= 0;
    }
}

return BaseLibrary
```

### Constants library — library_request_contants

Constants library embedded in the parent workflow as a Library node. Loaded with `ScriptUtils.evaluateFromNode("library_request_contants")`.

```groovy
import java.util.*;

class Constants {
    static final String LOG_FILENAME = "parallel-job";
    static final String VARIABLE_STATUS_PREFIX = "status_";

    // Context keys
    static final String CONTEXT_ACTOR                       = "actor";
    static final String CONTEXT_UUID                        = "uuid";
    static final String CONTEXT_INITIATOR                   = "initiator";
    static final String CONTEXT_SOURCE_PROCESS_INSTANCE_ID = "srcProcInsId";
    static final String CONTEXT_MANAGERS                    = "managers";
    static final String CONTEXT_MANAGERS_LIST               = "managersList";
    static final String CONTEXT_ACTOR_NUM                   = "actorNum";

    // Actors
    static final String ACTOR_COORDINATOR = "coordinator";
    static final String ACTOR_MANAGER1    = "manager1";
    static final String ACTOR_MANAGER2    = "manager2";

    // Child workflow form fields
    static final String FORM_FIELD_VOTE        = "vote";
    static final String FORM_FIELD_OBSERVATION = "observation";
    static final String FORM_FIELD_SPEAKER     = "speaker";

    // Metadata
    static final String METADATA_GROUP_EVENT_MANAGEMENT                   = "okg:event_management";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_REQUEST_NAME      = "okp:event_management.request_name";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_EVENT             = "okp:event_management.event";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_START_SESSION     = "okp:event_management.starting_session";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_DURATION          = "okp:event_management.duration";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_LOCATION          = "okp:event_management.location";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_COMMENTS          = "okp:event_management.comments";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_EXPECTED_BENEFITS = "okp:event_management.expected_benefits";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_STATUS            = "okp:event_management.status";
    static final String METADATA_EVENT_MANAGEMENT_FIELD_DEADLINE          = "okp:event_management.deadline";

    // Metadata status values
    static final String MANAGEMENT_STATUS_COORDINATOR_PENDING = "Coordinator pending";
    static final String MANAGEMENT_STATUS_MANAGER_PENDING     = "Managers pending";
    static final String MANAGEMENT_STATUS_REJECTED            = "Rejected";
    static final String MANAGEMENT_STATUS_APPROVED            = "Approved";

    // Context status values
    static final String STATUS_ENDED   = "ENDED";
    static final String STATUS_APPROVE = "APPROVE";
}

return Constants
```

### Parent workflow: Request-to-managers

#### Node: run_config (Initiation Form)

Start task assigned to `initiator`. The form collects the event request data before the workflow formally starts.

```xml
<workflow-form>
  <input label="Name"              name="request_name"      type="text">  <validator type="req"/></input>
  <select label="Event"            name="event"             type="simple"
     optionsQuery="select DEP_ID, DEP_NAME, DEP_TYPE FROM WF_DEPENDING where DEP_TYPE='event' order by DEP_NAME">
    <validator type="req"/>
  </select>
  <input label="Starting session"  name="starting_session"  type="date">  <validator type="req"/></input>
  <input label="Duration"          name="duration"          type="text">  <validator type="req"/> <validator type="num"/></input>
  <input label="Location"          name="location"          type="text">  <validator type="req"/></input>
  <textarea label="Comments"       name="comments"          type="text"/>
  <input label="Expected benefits" name="expected_benefits" type="text">  <validator type="req"/></input>
</workflow-form>
```

`assignExpr`: `return "initiator";`

#### Node: Add metadata (Action)

Creates or updates the metadata group on the document. Uses `getFormElementValueMap` to capture all form fields in a single call and build the properties map.

```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.util.*;

Class Constants   = ScriptUtils.evaluateFromNode("library_request_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

OKMWebservices ws = WebservicesHelper.getInstance();
String docUuid    = baseLibrary.getContextValue(context, Constants.CONTEXT_UUID);

// Capture multiple form fields in a single call
List<String> names = Arrays.asList(
    "request_name", "event", "starting_session", "duration",
    "location", "comments", "expected_benefits"
);
Map<String, String> values = WorkflowUtils.getFormElementValueMap(context, names);

Map<String, String> props = new HashMap();
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_REQUEST_NAME,      values.get("request_name"));
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_EVENT,             "[" + values.get("event") + "]");
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_START_SESSION,     values.get("starting_session"));
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_DURATION,          values.get("duration"));
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_LOCATION,          values.get("location"));
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_COMMENTS,          values.get("comments"));
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_EXPECTED_BENEFITS, values.get("expected_benefits"));
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_STATUS,            Constants.MANAGEMENT_STATUS_COORDINATOR_PENDING);

// Add the group if it does not exist; update it if it already does
if (ws.propertyGroup.hasGroup(docUuid, Constants.METADATA_GROUP_EVENT_MANAGEMENT)) {
    ws.propertyGroup.setProperties(docUuid, Constants.METADATA_GROUP_EVENT_MANAGEMENT, props);
} else {
    ws.propertyGroup.addGroup(docUuid, Constants.METADATA_GROUP_EVENT_MANAGEMENT, props);
}
```

#### Node: Task-coordinator (Task)

Task assigned to the `coordinator` user (constant from the library). The coordinator sees the request data in read-only mode (`readonly="true" data="<field>"`) and selects one or more managers in the multiple-select field.

`assignExpr`:
```groovy
Class Constants = ScriptUtils.evaluateFromNode("library_request_contants");
return Constants.ACTOR_COORDINATOR;
```

Relevant form fragment (multiple managers field):
```xml
<select label="Managers" name="managers" type="multiple">
  <option label="Manager1" value="manager1"/>
  <option label="Manager2" value="manager2"/>
  <validator type="req"/>
</select>
```

#### Node: Update metadata (Action)

Updates the metadata with the values entered by the coordinator (including `deadline`) and changes the status to `"Managers pending"`.

```groovy
List<String> names = Arrays.asList(
    "request_name", "event", "starting_session", "duration",
    "location", "comments", "expected_benefits", "deadline"
);
Map<String, String> values = WorkflowUtils.getFormElementValueMap(context, names);
Map<String, String> props  = new HashMap();
// ... same mapping as in Add metadata ...
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_DEADLINE, values.get("deadline"));
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_STATUS,   Constants.MANAGEMENT_STATUS_MANAGER_PENDING);
ws.propertyGroup.setProperties(docUuid, Constants.METADATA_GROUP_EVENT_MANAGEMENT, props);
```

#### Node: Run workflow managers (Action)

**Key node of the pattern.** Launches one child workflow per selected manager and records the child process IDs in the parent context.

```groovy
Class Constants   = ScriptUtils.evaluateFromNode("library_request_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

def processDefinition = WorkflowUtils.getProcessDefinitionByName("Manager-voting");
long managerPdId      = processDefinition.getId();

def uuid               = baseLibrary.getContextValue(context, Constants.CONTEXT_UUID);
def initiator          = baseLibrary.getInitiatorId(context);
def managerSelect      = baseLibrary.getContextValue(context, Constants.CONTEXT_MANAGERS);
def managerList        = managerSelect.value.tokenize(";"); // type="multiple" -> ";" separated value
long processInstanceId = baseLibrary.getProcessInstanceId(context);

// Keep the manager list in the context so scheduled_action can iterate over it
baseLibrary.setContextValue(context, Constants.CONTEXT_MANAGERS_LIST, managerList);

// Launch one child workflow per manager
def actorNum = 0;
managerList.each { manager ->
    actorNum++;
    // Props injected as context variables into the child workflow:
    //   uuid         -> associated document
    //   initiator    -> user who started the parent process
    //   actor        -> manager to whom the child task is assigned
    //   srcProcInsId -> parent process ID (so the child can notify the parent)
    def props  = [uuid: uuid, initiator: initiator, actor: manager, srcProcInsId: processInstanceId];
    def result = WorkflowUtils.runProcessDefinition(managerPdId, props);
    // Store the child process ID in a variable "pi_<manager>"
    context.put("pi_" + manager, result.get());
}

// Store the total number of managers for the scheduled check
baseLibrary.setContextValue(context, Constants.CONTEXT_ACTOR_NUM, actorNum);
```

#### Node: Waiting Task (Task)

Task assigned to the special user `system`. This user never interacts manually with the platform, so the task stays frozen indefinitely until `scheduled_action` completes it programmatically.

`assignExpr`: `return "system";`

The form is empty (`<workflow-form></workflow-form>`).

#### Node: scheduled_action (Scheduled)

Runs periodically (default every 60 s). Checks whether all child workflows have notified their completion by verifying the `status_<manager>` variables in the parent context. When the response count equals `actorNum`, it programmatically completes the waiting task.

```groovy
Class Constants   = ScriptUtils.evaluateFromNode("library_request_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

long processInstanceId = baseLibrary.getProcessInstanceId(context);
def managerList        = baseLibrary.getContextValue(context, Constants.CONTEXT_MANAGERS_LIST);
def actorNum           = baseLibrary.getContextValue(context, Constants.CONTEXT_ACTOR_NUM);
def uuid               = baseLibrary.getContextValue(context, Constants.CONTEXT_UUID);
def actorResp          = 0;

String waitingTask = "Waiting Task";
def taskInstance   = WorkflowUtils.getCurrentTaskInstance(processInstanceId);

if (waitingTask.equals(taskInstance.getName())) {
    managerList.each { manager ->
        def wfVar = Constants.VARIABLE_STATUS_PREFIX + manager; // "status_manager1", "status_manager2", ...
        if (context.get(wfVar) != null) {
            actorResp++;
        }
    }

    if (actorResp == actorNum) {
        // All children have finished -> advance the waiting task
        def props = [uuid: uuid];
        WorkflowUtils.setTaskInstanceValues(processInstanceId, waitingTask, null, props);
    }
}
```

#### Node: Calculate result (Action)

Once the waiting task advances, queries `WF_EVENT_VOTING` to aggregate the votes and builds a summary text passed to the next task as a `TextArea`.

```groovy
import com.openkm.bean.form.*;

Class Constants   = ScriptUtils.evaluateFromNode("library_request_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

OKMWebservices ws    = WebservicesHelper.getInstance();
String uuid          = baseLibrary.getContextValue(context, Constants.CONTEXT_UUID);
String totalManagers = baseLibrary.getContextValue(context, Constants.CONTEXT_ACTOR_NUM);

SqlQueryResults result = ws.repository.executeSqlQuery(
    "SELECT UUID, COUNT(*) AS total_votes, " +
    "SUM(CASE WHEN EP_VOTE = 'T' THEN 1 ELSE 0 END) AS votes_yes, " +
    "SUM(CASE WHEN EP_VOTE = 'F' THEN 1 ELSE 0 END) AS votes_no " +
    "FROM WF_EVENT_VOTING WHERE UUID = '" + uuid + "' GROUP BY UUID"
);

String totalVotes = "", yesVotes = "", noVotes = "";
for (SqlQueryResultColumns columns : result.getResults()) {
    totalVotes = columns.getColumns().get(1);
    yesVotes   = columns.getColumns().get(2);
    noVotes    = columns.getColumns().get(3);
}

StringBuilder sb = new StringBuilder();
sb.append("Total managers: ").append(totalManagers).append("\n");
sb.append("Total votes: ")   .append(totalVotes)   .append("\n");
sb.append("Votes YES: ")     .append(yesVotes)     .append("\n");
sb.append("Votes NO: ")      .append(noVotes);

TextArea textAreaResult = new TextArea();
textAreaResult.setValue(sb.toString());
context.put("textAreaResult", textAreaResult);
```

#### Node: Show the result to the coordinator (Task)

Shows the voting result to the coordinator in read-only mode and presents two action buttons.

`assignExpr`: returns `Constants.ACTOR_COORDINATOR`.

```xml
<workflow-form>
  <textarea label="Questionnaire result" name="questionnaire_result"
            data="textAreaResult" readonly="true"/>
  <button label="Approve" name="btn_approve" style="yes" color="success"
          validate="false" transition="approve"/>
  <button label="Reject"  name="btn_reject"  style="no"  color="danger"
          validate="false" transition="reject"
          confirmation="Are you sure to reject the request?"/>
</workflow-form>
```

#### Node: Approve action / Reject action (Action)

Both actions only update the status field in the document metadata before reaching the End node.

```groovy
// Approve action
Map<String, String> props = new HashMap();
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_STATUS, Constants.MANAGEMENT_STATUS_APPROVED);
ws.propertyGroup.setProperties(docUuid, Constants.METADATA_GROUP_EVENT_MANAGEMENT, props);

// Reject action
props.put(Constants.METADATA_EVENT_MANAGEMENT_FIELD_STATUS, Constants.MANAGEMENT_STATUS_REJECTED);
ws.propertyGroup.setProperties(docUuid, Constants.METADATA_GROUP_EVENT_MANAGEMENT, props);
```

### Child workflow: Manager-voting

#### Node: Questionnaire for managers (Task)

Task assigned to the manager indicated by the `actor` context variable (injected by the parent when launching the child workflow).

`assignExpr`:
```groovy
Class Constants   = ScriptUtils.evaluateFromNode("library_request_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");
String userId = baseLibrary.getContextValue(context, Constants.CONTEXT_ACTOR);
return userId;
```

Form:
```xml
<workflow-form>
  <select label="Vote" name="vote" type="simple">
    <option label="Yes" value="T"/>
    <option label="No"  value="F"/>
    <validator type="req"/>
  </select>
  <input    label="Speaker"     name="speaker"     type="text"><validator type="req"/></input>
  <textarea label="Observation" name="observation" type="text"/>
</workflow-form>
```

#### Node: Insert the response into the voting table (Action)

Saves the voting result to the intermediate table `WF_EVENT_VOTING`. This table is later queried by the parent in "Calculate result".

```groovy
Class Constants   = ScriptUtils.evaluateFromNode("library_request_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

OKMWebservices ws = WebservicesHelper.getInstance();
String docUuid    = baseLibrary.getContextValue(context, Constants.CONTEXT_UUID);
def initiatorId   = baseLibrary.getInitiatorId(context);

String vote        = WorkflowUtils.getFormElementValue(context, Constants.FORM_FIELD_VOTE);
String observation = WorkflowUtils.getFormElementValue(context, Constants.FORM_FIELD_OBSERVATION);
String speaker     = WorkflowUtils.getFormElementValue(context, Constants.FORM_FIELD_SPEAKER);

String sql = "INSERT INTO WF_EVENT_VOTING (UUID, USER_ID, EP_VOTE, EP_OBSERVATION, EP_SPEAKER) " +
             "VALUES ('" + docUuid + "', '" + initiatorId + "', '" + vote + "', '" +
             observation + "', '" + speaker + "');";
ws.repository.executeSqlQuery(sql);
```

#### Node: Notify the parent of the workflow completion (Action)

**Key node of the pattern.** Before ending, the child writes a variable into the parent's context using `WorkflowUtils.addProcessInstanceVariable`. The parent's process ID is available via the `srcProcInsId` variable injected at launch time.

```groovy
Class Constants   = ScriptUtils.evaluateFromNode("library_request_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

def piId  = baseLibrary.getContextValue(context, Constants.CONTEXT_SOURCE_PROCESS_INSTANCE_ID);
def actor = baseLibrary.getContextValue(context, Constants.CONTEXT_ACTOR);

// Write "status_<actor> = ENDED" into the parent process context
def wfVar = Constants.VARIABLE_STATUS_PREFIX + actor; // e.g. "status_manager1"
WorkflowUtils.addProcessInstanceVariable(piId, wfVar, Constants.STATUS_ENDED);
```

After this action the child workflow reaches the End node and terminates.

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| `WorkflowUtils.runProcessDefinition(pdId, props)` | "Run workflow managers" — launch N child instances, one per manager, passing context variables as `props` |
| `props = [uuid, initiator, actor, srcProcInsId]` | Variables injected into the child context at launch; `srcProcInsId` is the parent-notification channel |
| `WorkflowUtils.addProcessInstanceVariable(piId, key, value)` | Child "Notify" node — writes into the **parent's** context from the child |
| `status_<actor>` variable convention | Coordinator pattern: one variable per child, checked collectively by the scheduled node |
| `system` user on Waiting Task | Freezes the task indefinitely without requiring human input |
| `scheduled_action` polling loop | Counts completed children; calls `setTaskInstanceValues` when all done |
| `managerSelect.value.tokenize(";")` | Parsing a `type="multiple"` select field — value is semicolon-separated |

---

## Example 13 — Error Management

> Reference pattern showing how to catch exceptions inside Action nodes, store them in the workflow context, convert raw values to form elements, and present them to the user via read-only task fields. Also demonstrates automatic re-routing after an error and a manual recovery task.

### Overview

The workflow has no real business purpose beyond demonstrating error-handling patterns. It uses odd/even day-of-month as a contrived trigger to show:

1. **Catching exceptions inside Action nodes** and routing to named transitions (`ok` / `error`).
2. **Storing the exception and its origin** in context variables (`exception`, `errorOrigin`).
3. **Converting raw context values to form elements** (`Input`, `TextArea`) via an intermediate action before displaying them in a read-only task.
4. **Automatic re-routing** after an error (redirect back to the correct task without user intervention).
5. **Manual error management task** that shows the error details and lets the user decide to retry or end.

### Constants library — library_errors_management_contants

Library node embedded in the workflow. Loaded with `ScriptUtils.evaluateFromNode("library_errors_management_contants")`.

```groovy
class Constants {
    static final String LOG_FILENAME            = "errors-management";
    static final String VARIABLE_STATUS_PREFIX  = "status_";

    // Context keys
    static final String CONTEXT_INPUT               = "input";
    static final String CONTEXT_EXCEPTION           = "exception";
    static final String CONTEXT_ERROR_ORIGIN        = "errorOrigin";
    static final String CONTEXT_EXCEPTION_TEXTAREA  = "exceptionTextArea";
    static final String CONTEXT_ERROR_ORIGIN_INPUT  = "errorOriginInput";

    // Error origin identifiers
    static final String ERROR_ORIGIN_ACTION_CHECK_PARITY    = "actionCheckParity";
    static final String ERROR_ORIGIN_ACTION_CHECK_MAJOR_40  = "actionCheckMajor40";
    static final String ERROR_ORIGIN_ACTION_CHECK_MAJOR_20  = "actionCheckMajor20";
}

return Constants
```

### Node: Raise an error (Action)

Deliberately throws a dummy exception inside a `try/catch` to show that an Action node can throw, catch and still return a named transition. The actual routing is based on whether the current day is odd or even.

Transitions: `odd` → Ask for odd | `even` → Ask for even.

```groovy
import java.time.LocalDate;

try {
    throw new Exception("Dummy exception");
} catch (Exception e) {
    LocalDate currentDate = LocalDate.now();
    int day = currentDate.getDayOfMonth();

    if (day % 2 == 0) {
        return "even";
    } else {
        return "odd";
    }
}
```

### Node: Ask for odd / Ask for even (Task)

Both tasks ask the user to enter a number matching the required parity. They share the same form field name (`input`) so the downstream actions can read it uniformly.

`assignExpr`: `return baseLibrary.getInitiatorId(context);`

```xml
<!-- Ask for odd -->
<workflow-form>
  <input label="Odd number" name="input" type="text">
    <validator type="req"/> <validator type="num"/>
  </input>
</workflow-form>

<!-- Ask for even -->
<workflow-form>
  <input label="Even number" name="input" type="text">
    <validator type="req"/> <validator type="num"/>
  </input>
</workflow-form>
```

### Node: Check parity (Action)

**Pattern: catch-store-route.** Reads the user's number, validates parity against today's day. On failure it stores both the exception object and its origin string in the context, then returns `"error"`. On success it returns `"ok"`.

Transitions: `ok` → error when greater than 40 | `error` → Automatic error management.

```groovy
import java.time.LocalDate;

Class Constants   = ScriptUtils.evaluateFromNode("library_errors_management_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

try {
    String inputValue = WorkflowUtils.getFormElementValue(context, Constants.CONTEXT_INPUT);
    int number = Integer.parseInt(inputValue);

    LocalDate currentDate = LocalDate.now();
    int day = currentDate.getDayOfMonth();

    if (day % 2 == 0) {
        if (number % 2 != 0) throw new Exception("Expected even number");
    } else {
        if (number % 2 == 0) throw new Exception("Expected odd number");
    }

    return "ok";
} catch (Exception e) {
    baseLibrary.setContextValue(context, Constants.CONTEXT_ERROR_ORIGIN, Constants.ERROR_ORIGIN_ACTION_CHECK_PARITY);
    baseLibrary.setContextValue(context, Constants.CONTEXT_EXCEPTION, e);
    return "error";
}
```

### Node: error when greater than 40 (Action)

Same catch-store-route pattern. Validates the upper bound of 40.

Transitions: `ok` → error when greater than 20 | `error` → Automatic error management.

```groovy
Class Constants   = ScriptUtils.evaluateFromNode("library_errors_management_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

try {
    String inputValue = WorkflowUtils.getFormElementValue(context, Constants.CONTEXT_INPUT);
    int number = Integer.parseInt(inputValue);

    if (number > 40) throw new Exception("Number greater than 40");

    return "ok";
} catch (Exception e) {
    baseLibrary.setContextValue(context, Constants.CONTEXT_ERROR_ORIGIN, Constants.ERROR_ORIGIN_ACTION_CHECK_MAJOR_40);
    baseLibrary.setContextValue(context, Constants.CONTEXT_EXCEPTION, e);
    return "error";
}
```

### Node: error when greater than 20 (Action)

Same catch-store-route pattern. Validates the upper bound of 20.

Transitions: `ok` → transform errors data | `error` → Automatic error management.

```groovy
Class Constants   = ScriptUtils.evaluateFromNode("library_errors_management_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

try {
    String inputValue = WorkflowUtils.getFormElementValue(context, Constants.CONTEXT_INPUT);
    int number = Integer.parseInt(inputValue);

    if (number > 20) throw new Exception("Number greater than 20");

    return "ok";
} catch (Exception e) {
    baseLibrary.setContextValue(context, Constants.CONTEXT_ERROR_ORIGIN, Constants.ERROR_ORIGIN_ACTION_CHECK_MAJOR_20);
    baseLibrary.setContextValue(context, Constants.CONTEXT_EXCEPTION, e);
    return "error";
}
```

### Node: Automatic error management (Action)

Receives the `error` transition from all three validation nodes. Automatically re-routes the workflow back to the correct input task (odd or even) based on today's day — no user intervention required.

Transitions: `odd` → Ask for odd | `even` → Ask for even.

```groovy
import java.time.LocalDate;

LocalDate currentDate = LocalDate.now();
int day = currentDate.getDayOfMonth();

if (day % 2 == 0) {
    return "even";
} else {
    return "odd";
}
```

> The exception and errorOrigin stored in the context are preserved across this re-route and remain available when the workflow eventually reaches "transform errors data".

### Node: transform errors data (Action)

**Pattern: raw context value → form element.** Before a task can display a context value with the `data=` attribute it must be wrapped in the corresponding form-element type. This node converts the raw `Exception` and `String` into a `TextArea` and an `Input` respectively.

```groovy
import com.openkm.bean.form.*;

Class Constants   = ScriptUtils.evaluateFromNode("library_errors_management_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

Exception exception = baseLibrary.getContextValue(context, Constants.CONTEXT_EXCEPTION);
String errorOrigin  = baseLibrary.getContextValue(context, Constants.CONTEXT_ERROR_ORIGIN);

if (exception != null) {
    TextArea exceptionTextArea = new TextArea();
    exceptionTextArea.setName("exception");
    exceptionTextArea.setValue(exception.getMessage());
    baseLibrary.setContextValue(context, Constants.CONTEXT_EXCEPTION_TEXTAREA, exceptionTextArea);
}

if (errorOrigin != null) {
    Input errorOriginInput = new Input();
    errorOriginInput.setName("errorOrigin");
    errorOriginInput.setValue(errorOrigin);
    baseLibrary.setContextValue(context, Constants.CONTEXT_ERROR_ORIGIN_INPUT, errorOriginInput);
}
```

### Node: Show error information (Task)

Displays the error details to the initiator in read-only mode using the form elements prepared by "transform errors data".

```xml
<workflow-form>
  <input    label="Last error origin" name="errorOriginField" type="text"
            readonly="true" data="errorOriginInput"/>
  <textarea label="Last exception"    name="exceptionField"   type="text"
            readonly="true" data="exceptionTextArea"/>
</workflow-form>
```

### Node: finish or manage error (Action)

Decides programmatically whether human intervention is needed. Routes to "Manage error" if any error is stored in context; otherwise ends.

Transitions: `manage error` → Manage error | `end` → End.

```groovy
Class Constants   = ScriptUtils.evaluateFromNode("library_errors_management_contants");
Class baseLibrary = ScriptUtils.evaluateFromNode("library_global_base");

Exception exception = baseLibrary.getContextValue(context, Constants.CONTEXT_EXCEPTION);
String errorOrigin  = baseLibrary.getContextValue(context, Constants.CONTEXT_ERROR_ORIGIN);

if (exception != null || errorOrigin != null) {
    return "manage error";
} else {
    return "end";
}
```

### Node: Manage error (Task)

Manual recovery task. Presents the error details in read-only mode plus three action buttons.

Transitions: `odd` → Ask for odd | `even` → Ask for even | `end` → End.

```xml
<workflow-form>
  <separator label="Error information" name="errorInformation"/>
  <input    label="Last error origin" name="errorOriginField" type="text"
            readonly="true" data="errorOriginInput"/>
  <textarea label="Last exception"    name="exceptionField"   type="text"
            readonly="true" data="exceptionTextArea"/>
  <separator label="Decision" name="decision"/>
  <button label="Odd"  name="Odd"  transition="odd"  style="home" color="warning" validate="true"/>
  <button label="Even" name="even" transition="even" style="home" color="warning" validate="true"/>
  <button label="End"  name="end"  transition="end"  style="yes"  color="success" validate="true"
          confirmation="Are you sure you wish to end?"/>
</workflow-form>
```

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| `try/catch` with named transition return | All validation Action nodes — `return "ok"` on success, `return "error"` on failure |
| Store exception in context | `baseLibrary.setContextValue(context, CONTEXT_EXCEPTION, e)` — preserves the raw `Exception` object |
| Store error origin in context | `baseLibrary.setContextValue(context, CONTEXT_ERROR_ORIGIN, originConstant)` — symbolic identifier of the failing node |
| Raw value → form element conversion | "transform errors data" — wraps `Exception`/`String` into `TextArea`/`Input` before displaying via `data=` |
| `data=` binding on readonly field | Task form reads context key directly: `data="exceptionTextArea"`, `data="errorOriginInput"` |
| Programmatic error routing | "finish or manage error" — checks context for stored error and routes accordingly |
| Manual recovery task | "Manage error" — shows error + offers retry or end buttons |

---

## Example 14 — REST Requests from a Workflow

> Reference patterns for calling external REST services from a Groovy Action node. Shows two HTTP client approaches — standard Java `HttpURLConnection` and Spring's `RestTemplate` — against two public APIs (httpbin.org and Open-Meteo). All paths converge at a single action that assembles the response into a `TextArea` for display.

### Overview

The workflow presents four execution paths, chosen by the user at the first task:

| Button selected | URL selected | Action executed |
|---|---|---|
| RestTemplate sample | httpbin | RestTemplate httpbin |
| RestTemplate sample | meteo | RestTemplate meteo |
| HttpURLConnection sample | httpbin | HttpURLConnection httpbin |
| HttpURLConnection sample | meteo | HttpURLConnection meteo |

All four actions write their results into the same context keys (`jsonResponse`, `responseCode`, `origin`, `temperature`, `windspeed`) and converge at a single "add response variable in the context" action before the final task.

### Node: Choose request sample (Task)

The user selects a URL and clicks one of two buttons. The button click determines the transition (`RestTemplate` or `HttpURLConnection`); the select value is read downstream by the decision node.

```xml
<workflow-form>
  <select label="URL" name="url" type="simple">
    <option label="httpbin.org"        value="httpbin"/>
    <option label="api.open-meteo.com" value="meteo"/>
    <validator type="req"/>
  </select>
  <button label="RestTemplate sample"      name="RestTemplate"      style="yes" color="success" validate="true"/>
  <button label="HttpURLConnection sample" name="HttpURLConnection"  style="yes" color="success" validate="true"/>
</workflow-form>
```

`assignExpr`: `return context.get("initiator").getId();`

### Decision nodes: HttpURLConnection decide url / RestTemplate dedice url

Both decisions use the same script to read the `url` select value and route to `httpbin` or `meteo`.

```groovy
def url = context.get("url");
if ("httpbin".equals(url.getValue())) {
    return "httpbin";
} else {
    return "meteo";
}
```

> Reading a `select` form element: the context value is a `FormElement` object; use `.getValue()` to get the selected option string.

### Node: HttpURLConnection httpbin (Action)

Makes a GET request to `https://httpbin.org/get` using the standard Java `HttpURLConnection`. Parses the JSON response with Jackson to extract the `origin` field.

```groovy
import java.net.URL;
import java.net.HttpURLConnection;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;

URL url = new URL("https://httpbin.org/get");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", "Mozilla/5.0");
connection.setRequestProperty("Accept", "application/json");
connection.setConnectTimeout(10000);
connection.setReadTimeout(10000);

BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
StringBuilder jsonResponse = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) { jsonResponse.append(line); }
reader.close();
connection.disconnect();

String jsonResponseStr = jsonResponse.toString();
context.put("jsonResponse", jsonResponseStr);

ObjectMapper mapper = new ObjectMapper();
JsonNode json = mapper.readTree(jsonResponseStr);
context.put("origin", json.get("origin").asText());
```

### Node: HttpURLConnection meteo (Action)

Same `HttpURLConnection` pattern against the Open-Meteo weather API. Extracts `temperature` and `windspeed` from the nested `current_weather` object.

```groovy
import java.net.URL;
import java.net.HttpURLConnection;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;

URL url = new URL("https://api.open-meteo.com/v1/forecast?latitude=40.4168&longitude=-3.7038&current_weather=true");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
connection.setConnectTimeout(10000);
connection.setReadTimeout(10000);

BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
StringBuilder jsonResponse = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) { jsonResponse.append(line); }
reader.close();
connection.disconnect();

String jsonResponseStr = jsonResponse.toString();
context.put("jsonResponse", jsonResponseStr);

ObjectMapper mapper = new ObjectMapper();
JsonNode json = mapper.readTree(jsonResponseStr);
JsonNode currentWeather = json.get("current_weather");
context.put("temperature", currentWeather.get("temperature").asDouble());
context.put("windspeed",   currentWeather.get("windspeed").asDouble());
```

### Node: RestTemplate httpbin (Action)

Makes the same request to httpbin but uses Spring's `RestTemplate`. Includes a trust-all SSL configuration suitable for testing environments where certificate validation is not required.

```groovy
import org.springframework.web.client.RestTemplate;
import org.springframework.http.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import javax.net.ssl.*;
import java.net.HttpURLConnection;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;

// Trust-all SSL manager — for testing only, do not use in production
TrustManager[] trustAllCerts = [
    new X509TrustManager() {
        public X509Certificate[] getAcceptedIssuers() { return null; }
        public void checkClientTrusted(X509Certificate[] c, String a) {}
        public void checkServerTrusted(X509Certificate[] c, String a) {}
    }
] as TrustManager[];

SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new SecureRandom());
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();

SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() {
    @Override
    protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException {
        if (connection instanceof HttpsURLConnection) {
            HttpsURLConnection httpsConnection = (HttpsURLConnection) connection;
            httpsConnection.setSSLSocketFactory(sslSocketFactory);
            httpsConnection.setHostnameVerifier((hostname, session) -> true);
        }
        super.prepareConnection(connection, httpMethod);
    }
};
requestFactory.setConnectTimeout(15000);
requestFactory.setReadTimeout(15000);

RestTemplate restTemplate = new RestTemplate(requestFactory);

HttpHeaders headers = new HttpHeaders();
headers.set("User-Agent", "Mozilla/5.0");
headers.set("Accept", "application/json");
HttpEntity<String> entity = new HttpEntity<>(headers);

ResponseEntity<String> response = restTemplate.exchange(
    "https://httpbin.org/get", HttpMethod.GET, entity, String.class
);

String jsonResponseStr = response.getBody();
context.put("jsonResponse",  jsonResponseStr);
context.put("responseCode",  response.getStatusCode().value());

ObjectMapper mapper = new ObjectMapper();
JsonNode json = mapper.readTree(jsonResponseStr);
context.put("origin", json.get("origin").asText());
```

### Node: RestTemplate meteo (Action)

Same `RestTemplate` + trust-all SSL setup against Open-Meteo.

```groovy
// (same SSL setup as RestTemplate httpbin — omitted for brevity)

ResponseEntity<String> response = restTemplate.exchange(
    "https://api.open-meteo.com/v1/forecast?latitude=40.4168&longitude=-3.7038&current_weather=true",
    HttpMethod.GET, entity, String.class
);

String jsonResponseStr = response.getBody();
context.put("jsonResponse",  jsonResponseStr);
context.put("responseCode",  response.getStatusCode().value());

ObjectMapper mapper = new ObjectMapper();
JsonNode json = mapper.readTree(jsonResponseStr);
JsonNode currentWeather = json.get("current_weather");
context.put("temperature", currentWeather.get("temperature").asDouble());
context.put("windspeed",   currentWeather.get("windspeed").asDouble());
```

### Node: add response variable in the context (Action)

**Convergence point for all four paths.** Reads whichever context variables were populated by the chosen action, assembles them into a formatted string, and wraps the result in a `TextArea` for display via `data="restData"`.

```groovy
import com.openkm.bean.form.*;

def temperature  = context.get("temperature");
def windspeed    = context.get("windspeed");
def origin       = context.get("origin");
def jsonResponse = context.get("jsonResponse");
def responseCode = context.get("responseCode");

StringBuilder sb = new StringBuilder();
sb.append("---------------------------------\n");
sb.append("temperature: ").append(temperature  != null ? temperature  : "").append("\n");
sb.append("windspeed: ")  .append(windspeed    != null ? windspeed    : "").append("\n");
sb.append("---------------------------------\n");
sb.append("origin: ")     .append(origin       != null ? origin       : "").append("\n");
sb.append("---------------------------------\n");
sb.append("jsonResponse: ").append(jsonResponse != null ? jsonResponse : "").append("\n");
sb.append("responseCode: ").append(responseCode != null ? responseCode : "").append("\n");
sb.append("---------------------------------\n");

TextArea textArea = new TextArea();
textArea.setName("restData");
textArea.setValue(sb.toString());
context.put("restData", textArea);
```

### Node: Task (Task)

Displays the assembled REST response to the initiator in a read-only textarea.

```xml
<workflow-form>
  <textarea label="Rest data" name="restData" data="restData"/>
</workflow-form>
```

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| `HttpURLConnection` GET request | httpbin and meteo Action nodes — standard Java, no external dependency |
| `RestTemplate` GET request | httpbin and meteo Action nodes — Spring HTTP client, more control over SSL and headers |
| Trust-all SSL `TrustManager` | RestTemplate nodes — bypass certificate validation for testing environments |
| `url.getValue()` on a `select` field | Decision node scripts — reading the selected option from a `FormElement` context value |
| Multi-path convergence | "add response variable in the context" — single action handles output from all four upstream paths |
| Null-safe context assembly | Build output from whichever subset of keys (`origin`, `temperature`, etc.) was actually populated |
| `TextArea` wrapping for display | Wrap assembled string in `new TextArea()` before storing in context so the task can bind it via `data=` |

---

## Pattern — Sequential Counters (human-readable reference numbers)

A common workflow requirement is generating sequential, human-readable identifiers (e.g. `PO-2026-001`, `INV-2026-042`). `OKMConfig` (via `ws.repository.getConfiguration(key)`) has no corresponding write method in the SDK, so it is not suitable for a counter, and timestamps are unique but not sequential or readable. The supported pattern is a dedicated database table queried via `ws.repository.executeSqlQuery`, following the same approach used for the intermediate `WF_EVENT_VOTING` table in Example 12.

**SQL setup (run once per environment):**
```sql
CREATE TABLE IF NOT EXISTS APP_COUNTER (
    counter_key   VARCHAR(50) NOT NULL,
    counter_value INT         NOT NULL DEFAULT 1,
    CONSTRAINT pk_app_counter PRIMARY KEY (counter_key)
);
INSERT INTO APP_COUNTER (counter_key, counter_value) VALUES ('my_workflow', 1);
```

**Groovy usage in an Action node:**
```groovy
// Read current value
SqlQueryResults result = ws.repository.executeSqlQuery(
    "SELECT counter_value FROM APP_COUNTER WHERE counter_key = 'my_workflow'");
int current = Integer.parseInt(result.getResults().get(0).getColumns().get(0));

// Increment
ws.repository.executeSqlQuery(
    "UPDATE APP_COUNTER SET counter_value = " + (current + 1) +
    " WHERE counter_key = 'my_workflow'");

// Use the value
String reference = String.format("REF-%d-%03d", Calendar.getInstance().get(Calendar.YEAR), current);
```

> `APP_COUNTER` is a generic pattern — multiple workflows can share the same table by using different `counter_key` values. No schema changes are needed to add a counter for a new workflow.

---

## Template — Vacation Request

**Folder:** `template-vacation-request/`
**Process file:** `template-vacation-request/vacation-request.okmflow`

### Description

End-to-end vacation request workflow. The employee submits a date range before the workflow starts. The request is then reviewed at one or two management levels. Each reviewer can approve, deny, escalate to a higher level, or request corrections from the employee. When changes are requested the request always routes back to the same reviewer who originally requested them. Final outcome is communicated to the employee by email.

**Supported paths:**
- Direct approval by the first-level manager.
- Escalation to a second-level reviewer when the manager considers it necessary.
- Request for changes at either level — routes back to the employee, then back to the same reviewer.
- Denial at either level with automatic email notification.

**Prerequisites:**

| Requirement | Description |
|-------------|-------------|
| Role `ROLE_RRHH` | Must exist with at least one user assigned — forms the pool of first-level reviewers. |
| User `rrhhSupervisor` | Must exist in OpenKM — hardcoded assignee for the second-level approval task. |

### Node flow

```
run_config (pre-workflow)
Start → [Action] Build user name text → [Task] Manager approval
                                              ├─(approve)──── [Action] Format dates approved → [Mail] Notify approved → End[Approved]
                                              ├─(deny)──────── [Action] Format dates denied  → [Mail] Notify denied  → End[Denied]
                                              ├─(escalate)──── [Task] Second level approval
                                              │                      ├─(approve)────── [Action] Format dates approved → [Mail] Notify approved → End[Approved]
                                              │                      ├─(deny)────────── [Action] Format dates denied  → [Mail] Notify denied  → End[Denied]
                                              │                      └─(request_changes)─→ [Action] Set sender second level ──┐
                                              └─(request_changes)─→ [Action] Set sender manager ──────────────────────────────┤
                                                                                                                              ↓
                                                                                                         [Task] Submit vacation request
                                                                                                                              ↓
                                                                                                [Decision] Who requested changes?
                                                                                                      ├─(manager)──────── Manager approval
                                                                                                      └─(second_level)─── Second level approval
```

---

#### Node: Task — `run_config`

Special pre-workflow initiation form. Displayed to the employee before the workflow starts. Collects the vacation period dates.

**Assignment:** `return "";`

**Form:**
```xml
<workflow-form>
  <text label="&lt;span class=&quot;font-weight-bold&quot;&gt;Vacation period request&lt;/span&gt;"
        name="reg_030_usuario" data="userText" />
  <input label="Start date" name="start_date" type="date" timeFormat="none">
    <validator type="req"/>
  </input>
  <input label="End date" name="end_date" type="date" timeFormat="none">
    <validator type="req"/>
  </input>
</workflow-form>
```

**Key point:** date input fields with `type="date"` store their value in the context as a `yyyyMMddHHmmss` string (e.g. `20260601000000`). The time portion is always `000000` when `timeFormat="none"` is set. This raw format must be converted before displaying it in email bodies — see the "Format dates" actions below.

---

#### Node: Start — `workflow.start`

Standard start node. Unnamed transition → "Build user name text".

---

#### Node: Action — "Build user name text"

Runs immediately after Start. Creates a `Text` form element with the employee's name embedded as HTML, and stores it in context so the approval task forms can display it as a dynamic header.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

Actor actor = context.get("initiator");
String userName = actor.getName();
Text userText = new Text();
userText.setName("user_text");
userText.setLabel("The user <span class=\"font-weight-bold\">" + userName + "</span> has requested vacations");
context.put("user_text", userText);
```

**Key points:**
- `Text` is a form element that displays read-only rich content inside a task form. Its `label` property is rendered as HTML.
- Created programmatically in an Action node exactly like `Input` or `TextArea`: `new Text()`, `setName()`, `setLabel()`, then `context.put()`.
- In the form, bind it with `<text name="..." data="user_text" />` — the `data` attribute loads the `Text` object from context, overriding the static `label` in the XML with the one from the object.
- `actor.getName()` returns the display name of the user (not the ID). Use this when the form should show a human-readable name.

**Transition:** unnamed → "Manager approval".

---

#### Node: Task — "Manager approval"

Assigned to the pool of users in `ROLE_RRHH` on first entry. On re-entry (after the employee corrects the request), uses `WorkflowUtils.getTaskInstances()` to find the previous actor and assigns the task **directly** to them — skipping the pool.

**Assignment expression:**
```groovy
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.rest.dto.*;
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

def procIns = context.get("processInstance");
long piId = procIns.getId();

// Re-entry: find who handled this task previously and assign directly (not as pool)
List<TaskInstanceDTO> prevTasks = WorkflowUtils.getTaskInstances(piId, "Manager approval");
if (prevTasks != null && !prevTasks.isEmpty()) {
    for (TaskInstanceDTO t : prevTasks) {
        if (t.getActor() != null && !t.getActor().isEmpty()) {
            return t.getActor(); // String → direct assignment
        }
    }
}

// First entry: assign to pool of ROLE_RRHH users
OKMWebservices ws = WebservicesHelper.getInstance();
List<String> users = new ArrayList();
for (CommonUser user : ws.auth.getUsersByRole("ROLE_RRHH")) {
    users.add(user.getId());
}
return users; // List → pool assignment
```

**Key point:** `getTaskInstances()` returns task history ordered by id ASC. The loop finds the first entry with a non-null actor (the person who previously claimed and handled the task from the pool). Returning that value as a `String` (not a List) creates a direct assignment — the user does not need to self-assign again.

**Form:**
```xml
<workflow-form>
  <text label="" name="user_description" data="user_text" />
  <input label="Start date" name="start_date" type="date" timeFormat="none"
         data="start_date" readonly="true"/>
  <input label="End date"   name="end_date"   type="date" timeFormat="none"
         data="end_date"   readonly="true"/>
  <textarea label="Comment" name="manager_comment">
    <validator type="req"/>
  </textarea>
  <button name="btn_approve"          label="Approve"          transition="approve"         color="success"   style="yes"    validate="true"/>
  <button name="btn_request_changes"  label="Request changes"  transition="request_changes" color="warning"   style="change" validate="true"/>
  <button name="btn_escalate"         label="Escalate"         transition="escalate"        color="secondary" style="add"    validate="true"/>
  <button name="btn_deny"             label="Deny"             transition="deny"            color="danger"    style="no"     validate="true"
          confirmation="Are you sure you want to deny this request?"/>
</workflow-form>
```

**Outgoing transitions:** `approve` → Format dates approved | `deny` → Format dates denied | `escalate` → Second level approval | `request_changes` → Set sender manager.

---

#### Node: Action — "Set sender manager"

Triggered after the manager selects `request_changes`. Stores the routing key and propagates the reviewer's comment to the shared correction task.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Store routing key so the Decision node routes back here
Input sender = new Input();
sender.setValue("manager");
context.put("request_sender", sender);

// Propagate the manager's comment to the shared correction task (type-neutral copy)
def managerComment = context.get("manager_comment");
context.put("reviewer_comment", managerComment);
```

**Key point:** `manager_comment` is a `TextArea` (form field from the Manager approval task). Using `def` instead of `TextArea managerComment = (TextArea) context.get(...)` makes the copy type-neutral — if the form definition changes, no cast update is needed. The comment is stored under `reviewer_comment`, the shared key read by "Submit vacation request".

**Transition:** unnamed → "Submit vacation request".

---

#### Node: Task — "Second level approval"

**Assignment:** `return "rrhhSupervisor";`

Reached when the first-level manager escalates. Also shows the manager's comment (read-only) so the second-level reviewer has full context.

**Form:**
```xml
<workflow-form>
  <text label="" name="user_description" data="user_text" />
  <input label="Start date"     name="start_date"      type="date" timeFormat="none" data="start_date"      readonly="true"/>
  <input label="End date"       name="end_date"        type="date" timeFormat="none" data="end_date"        readonly="true"/>
  <textarea label="Manager comment" name="manager_comment" data="manager_comment" readonly="true"/>
  <textarea label="Comment" name="second_level_comment">
    <validator type="req"/>
  </textarea>
  <button name="btn_approve"         label="Approve"         transition="approve"         color="success" style="yes"    validate="true"/>
  <button name="btn_request_changes" label="Request changes" transition="request_changes" color="warning" style="change" validate="true"/>
  <button name="btn_deny"            label="Deny"            transition="deny"            color="danger"  style="no"     validate="true"
          confirmation="Are you sure you want to deny this request?"/>
</workflow-form>
```

**Key point:** `data="manager_comment"` on the second textarea pre-fills it with the `TextArea` object stored in context by the Manager approval task. Since the field is `readonly="true"`, it acts as a display-only view of the first-level reviewer's comment.

**Outgoing transitions:** `approve` → Format dates approved | `deny` → Format dates denied | `request_changes` → Set sender second level.

---

#### Node: Action — "Set sender second level"

Mirrors the logic of "Set sender manager" but for the second level.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

Input sender = new Input();
sender.setValue("second_level");
context.put("request_sender", sender);

def secondComment = context.get("second_level_comment");
context.put("reviewer_comment", secondComment);
```

**Transition:** unnamed → "Submit vacation request".

---

#### Node: Task — "Submit vacation request"

**Assignment:** `return ((Actor) context.get("initiator")).getId();`

Single shared correction task used for re-entries from both the first and second level. The employee can adjust dates and always sees the comment from whoever requested the changes, regardless of which reviewer triggered the return.

**Form:**
```xml
<workflow-form>
  <input label="Start date" name="start_date" type="date" timeFormat="none" data="start_date">
    <validator type="req"/>
  </input>
  <input label="End date" name="end_date" type="date" timeFormat="none" data="end_date">
    <validator type="req"/>
  </input>
  <textarea label="Reviewer comment" name="reviewer_comment" data="reviewer_comment" readonly="true"/>
  <button name="btn_submit" label="Submit" transition="" color="primary" style="yes" validate="true"/>
</workflow-form>
```

**Key points:**
- `data="start_date"` and `data="end_date"` pre-fill the date fields with the previously entered values so the employee only changes what is needed.
- `data="reviewer_comment"` loads the `reviewer_comment` variable written by the preceding Action node ("Set sender manager" or "Set sender second level"). This is a unified variable regardless of review level — the two Action nodes write different comment objects into the same context key.
- The employee can modify the dates. When submitted, the new values overwrite `start_date` and `end_date` in context.

**Transition:** unnamed → "Who requested changes?".

---

#### Node: Decision — "Who requested changes?"

Routes the corrected request back to the reviewer who requested changes, based on the `request_sender` context variable.

**Script:**
```groovy
import com.openkm.okmflow.util.*;
import com.openkm.bean.form.*;

def sender = context.get("request_sender");
return sender.getValue(); // returns "manager" or "second_level"
```

| Transition | Target |
|-----------|--------|
| `manager` | Manager approval |
| `second_level` | Second level approval |

**Key point:** `request_sender` holds an `Input` object whose `.getValue()` returns the routing key written by the preceding Action node. Using `def` for the cast is safe here — but since the type is reliably `Input` (both Action nodes use `new Input()`), an explicit cast would also be correct.

---

#### Nodes: Action — "Format dates approved" / "Format dates denied"

Two symmetric nodes, executed immediately before each Mail node. Convert the raw date format stored in context (`yyyyMMddHHmmss`, e.g. `20260601000000`) to `dd/MM/yyyy` and store the results as new `Input` objects.

**Script (identical in both):**
```groovy
import com.openkm.bean.form.*;
import java.text.*;

SimpleDateFormat raw = new SimpleDateFormat("yyyyMMddHHmmss");
SimpleDateFormat fmt = new SimpleDateFormat("dd/MM/yyyy");

Input startRaw = context.get("start_date");
Input endRaw   = context.get("end_date");

Input startFmt = new Input();
startFmt.setValue(fmt.format(raw.parse(startRaw.getValue())));
context.put("start_date_fmt", startFmt);

Input endFmt = new Input();
endFmt.setValue(fmt.format(raw.parse(endRaw.getValue())));
context.put("end_date_fmt", endFmt);
```

**Key points:**
- OKMFlow stores `date` input field values in context using the format `yyyyMMddHHmmss` (e.g. `20260601000000`). Always parse with `new SimpleDateFormat("yyyyMMddHHmmss")` before further formatting.
- The formatted values are stored as new `Input` objects so the Mail node template can access them via the `.value` FreeMarker accessor: `${start_date_fmt.value}`.
- Two separate action nodes are used (one per path) to keep the conversion close to the Mail node that uses the results.

**Transition:** unnamed → respective Mail node.

---

#### Node: Mail — "Notify approved"

Sends an approval email to the workflow initiator.

```
recipients: ${initiator.email}
subject:    Your vacation request has been approved
body:
  <p>Dear ${initiator.name},</p>
  <p>Your vacation request from <strong>${start_date_fmt.value}</strong>
     to <strong>${end_date_fmt.value}</strong> has been <strong>approved</strong>.</p>
  <p>Best regards,</p>
```

**Transition:** unnamed → End[Approved].

---

#### Node: Mail — "Notify denied"

Sends a denial email to the workflow initiator. Shared by denials from both levels.

```
recipients: ${initiator.email}
subject:    Your vacation request has been denied
body:
  <p>Dear ${initiator.name},</p>
  <p>Your vacation request from <strong>${start_date_fmt.value}</strong>
     to <strong>${end_date_fmt.value}</strong> has been <strong>denied</strong>.</p>
  <p>Reason: ${reviewer_comment.value}</p>
  <p>Best regards,</p>
```

**Key point:** `${reviewer_comment.value}` accesses the `.value` property of the form element stored in context under `reviewer_comment`. This is the FreeMarker syntax for reading form element values inside a Mail node body — not `.getValue()` (that is Groovy). The `reviewer_comment` variable is populated by whichever "Set sender" action node ran last, so a single Mail node serves both denial paths.

**Transition:** unnamed → End[Denied].

---

#### Nodes: End — "Approved" / "Denied"

Two named End nodes representing the two final outcomes. Using distinct names allows external systems or reports to distinguish whether a process instance ended in approval or denial.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|---------------|
| `Text` form element as dynamic HTML header | Action "Build user name text" creates `new Text()` with `setLabel(html)`, stored in context; task form binds it with `<text data="user_text"/>` |
| Pool → direct assignment on re-entry | `getTaskInstances(piId, taskName)` finds previous actor; returning a `String` (not `List`) creates a direct re-assignment |
| Single shared correction task for multiple review levels | "Submit vacation request" used after both first and second level `request_changes` — `reviewer_comment` context key unified by the two preceding Action nodes |
| `def` for type-neutral context copy | `def comment = context.get("manager_comment"); context.put("reviewer_comment", comment)` — avoids `ClassCastException` if form type changes |
| Decision routing from a context `Input` value | `def sender = context.get("request_sender"); return sender.getValue()` — routing key written by the preceding Action node |
| Date format conversion for Mail node | `date` input fields store values as `yyyyMMddHHmmss`; parse with `SimpleDateFormat("yyyyMMddHHmmss")`, reformat to `dd/MM/yyyy`, store result as `Input` |
| Mail node template — `${element.value}` accessor | `${start_date_fmt.value}`, `${reviewer_comment.value}` — FreeMarker syntax (not Groovy `.getValue()`) |
| Multi-level approval with escalation | Manager approval node has 4 transitions: approve / deny / escalate / request_changes |
| Two named End nodes | `Approved` and `Denied` — distinguishable termination paths for external reporting |
| `reviewer_comment` as a unified display key | Both "Set sender" actions write to the same context key, so a single `data="reviewer_comment"` field in the correction form always shows the relevant reviewer's comment |

---

## Template — Purchase Order

**Folder:** `purchase-order/`
**Process file:** `purchase-order/purchase-order.okmflow`
**Metadata:** `purchase-order/metadata.xml`
**SQL:** `purchase-order/scripts.sql`

### Description

Full purchase order request workflow. The requester submits a purchase request, uploads supporting documents, and the system creates a dedicated record in the repository. The request passes through budget validation and manager approval before a purchase order is formally issued. A clarification loop allows the budget validator to return the request to the requester for corrections before re-validation.

This workflow does **not** start on an existing OpenKM node. It creates its own repository structure (a record) at startup and attaches the process instance to it via `WorkflowUtils.setProcessInstanceNode(context, recordUuid)`.

**Prerequisites:**

| Requirement | Description |
|-------------|-------------|
| `APP_COUNTER` table (`scripts.sql`) | Generic sequential-counter table shared across workflows (see "Pattern — Sequential Counters" above). Must exist with a `purchase_order` row before the first execution. |
| Role `ROLE_FINANCE` | Pool for budget validation when the estimated amount is below 3,000. Must have at least one user assigned. |
| User `financeManager` | Direct assignment for budget validation when the estimated amount is 3,000 or more. Also handles Manager Approval for the Finance department. |
| Users `itManager`, `operationsManager`, `salesManager`, `hrManager` | Direct assignment for Manager Approval, one per department. |
| Metadata group `okg:purchase_order` (`metadata.xml`) | `readonly="true"` — written exclusively by Action nodes, never by the user. Fields: `poNumber`, `requestTitle`, `department`, `requestDate`, `estimatedAmount`, `currency`, `actualAmount`, `priority`, `requiredByDate`, `vendor`, `description`, `poOfficialNumber`, `deliveryDate`, `status`. |

**SQL setup (`scripts.sql`, run once per environment):**
```sql
CREATE TABLE APP_COUNTER (
    counter_key VARCHAR(50) PRIMARY KEY,
    counter_value INT NOT NULL DEFAULT 1
);
INSERT INTO APP_COUNTER (counter_key, counter_value) VALUES ('purchase_order', 1);
```

> If another template that also relies on `APP_COUNTER` (e.g. `expense-report-approval`) is deployed on the same environment, run only one `CREATE TABLE` statement for the shared table and add the remaining `INSERT` rows — the table itself must not be created twice.

### Repository structure created at startup

```
/okm:root/
└── Purchase Order Request/
    └── {year}/                    ← folder (ws.folder.createMissingFolders)
        └── PO-{year}-{NNN}/       ← record (ws.record.create)
            └── others/            ← folder (ws.folder.create) — for non-budget documents
```

### Node flow

```
Start
  ↓
[Action] Create PO folder
  ↓
[Task] Request Submission
  ↓
[Action] Save metadata
  ↓
[Task] Budget Validation ←────────────────────────────────────────┐
  ├─(approved)──→ [Action] Set status - Pending Approval           │
  │                   ↓                                            │
  │               [Task] Manager Approval                          │
  │                   ├─(approved)──→ [Action] Set status - Approved
  │                   │                   ↓                        │
  │                   │               [Task] Purchase Order        │
  │                   │                   ↓                        │
  │                   │               [Action] Set status - Issued │
  │                   │                   ↓                        │
  │                   │               [Mail] PO Issued → End       │
  │                   └─(rejected)──→ [Mail] Rejected by Manager → End
  ├─(rejected)──→ [Mail] Rejected by Budget → End                  │
  └─(needs_clarification)                                          │
        ↓                                                          │
    [Action] Set status - Clarification                            │
        ↓                                                          │
    [Task] Clarification Request                                   │
        ↓                                                          │
    [Action] Set status - Pending Budget ─────────────────────────┘
```

---

#### Node: Start — `workflow.start`

Standard start node. This workflow starts **without an associated OpenKM node** — `uuid` and `node` are not present in the initial context. Unnamed transition → "Create PO folder".

---

#### Node: Action — "Create PO folder"

Creates the entire repository structure for the new request and initialises all context variables needed by subsequent nodes.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.Calendar;

OKMWebservices ws = WebservicesHelper.getInstance();

// Read and increment counter from APP_COUNTER table
SqlQueryResults counterResult = ws.repository.executeSqlQuery(
    "SELECT counter_value FROM APP_COUNTER WHERE counter_key = 'purchase_order'");
int currentValue = Integer.parseInt(counterResult.getResults().get(0).getColumns().get(0));
int nextValue = currentValue + 1;
ws.repository.executeSqlQuery(
    "UPDATE APP_COUNTER SET counter_value = " + nextValue + " WHERE counter_key = 'purchase_order'");

// Build PO reference: PO-YYYY-NNN
int year = Calendar.getInstance().get(Calendar.YEAR);
String poNumber = String.format("PO-%d-%03d", year, currentValue);
Input poNumberInput = new Input();
poNumberInput.setName("poNumber");
poNumberInput.setValue(poNumber);
context.put("poNumber", poNumberInput);
FileLogger.info("purchase-order-request", "PO number generated: " + poNumber);

// Step 1: create year folder if it does not exist (folder type)
String yearFolderPath = "/okm:root/Purchase Order Request/" + year;
if (!ws.repository.hasNode(yearFolderPath)) {
  ws.folder.createMissingFolders(yearFolderPath);
}
FileLogger.info("purchase-order-request", "Year folder ready: " + yearFolderPath);

// Step 2: create the PO record inside the year folder (record type)
String fldUuid = ws.repository.getNodeUuid(yearFolderPath);
String recordPath = yearFolderPath + "/" + poNumber;
if (!ws.repository.hasNode(recordPath)) {
  ws.record.create(fldUuid, poNumber, "", 0);
}
FileLogger.info("purchase-order-request", "PO record created: " + recordPath);

// Step 3: create "others" subfolder inside the record for non-budget documents
String recordUuid = ws.repository.getNodeUuid(recordPath);
String othersFolderUuid = "";
String othersFolderPath = recordPath + "/others";
if (!ws.repository.hasNode(othersFolderPath)) {
  Folder fld = ws.folder.create(recordUuid, "others");
  othersFolderUuid = fld.getUuid();
} else {
  othersFolderUuid = ws.repository.getNodeUuid(othersFolderPath);
}
FileLogger.info("purchase-order-request", "Others folder ready: " + othersFolderPath);

// Store Upload context objects so each upload field targets the correct destination
Upload budgetUpload = new Upload();
budgetUpload.setName("budgetDocumentData");
budgetUpload.setFolderUuid(recordUuid);
context.put("budgetDocumentData", budgetUpload);

Upload otherUpload = new Upload();
otherUpload.setName("otherDocumentsData");
otherUpload.setFolderUuid(othersFolderUuid);
context.put("otherDocumentsData", otherUpload);

context.put("poRecordPath", recordPath);
context.put("poRecordUuid", recordUuid);
context.put("poOthersFolderUuid", othersFolderUuid);

// Attach this workflow process instance to the PO record node
WorkflowUtils.setProcessInstanceNode(context, recordUuid);
FileLogger.info("purchase-order-request", "Process instance attached to record " + recordUuid);
```

**Key points:**
- `ws.repository.executeSqlQuery(String sql)` — executes a SQL string directly. Used here to read and write a counter row in the custom `APP_COUNTER` table (see "Pattern — Sequential Counters").
- `ws.record.create(parentUuid, name, title, nodeClass)` — creates a record node. The third parameter is the title (can be an empty string); the fourth is the node class ID (`0` = none). `ws.record.createMissingRecords()` does **not exist** in the runtime — always use `ws.record.create()`.
- `ws.folder.create(parentUuid, name)` — creates a single folder and returns the `Folder` object. `fld.getUuid()` gives the UUID directly.
- `WorkflowUtils.setProcessInstanceNode(context, uuid)` — links the process instance to the PO record. The first parameter is the `context` binding variable (not a process instance ID). Internally updates `context.put("uuid", ...)`, `context.put("node", ...)`, and `context.put("nodeName", ...)`.
- `poNumber` is stored as an `Input` object (not a plain `String`) so it can be bound to form fields via `data="poNumber"` and referenced in Mail node templates as `${poNumber.value}`.
- `Upload` descriptors use context keys (`budgetDocumentData`, `otherDocumentsData`) that differ from the form field `name` attributes (`budgetDocument`, `otherDocuments`) used in "Request Submission". This is mandatory: if the context key and the field name are the same, the engine overwrites the `Upload` descriptor with the form element object on submit, destroying the `folderUuid` for subsequent tasks that depend on it.

**Transition:** unnamed → "Request Submission".

---

#### Node: Task — "Request Submission"

**Assignment:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Assign to the user who started the workflow
Actor actor = (Actor) context.get("initiator");
return actor.getId();
```

The requester fills in the request details and uploads documents. The `Upload` descriptors created in "Create PO folder" route each upload field to the correct repository destination.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <text label="&lt;strong&gt;Purchase Order Request&lt;/strong&gt;" name="headerTitle" />
  <input label="PO Reference" name="poNumberDisplay" type="text" readonly="true" data="poNumber" />
  <input label="Request Title" name="requestTitle" type="text" timeFormat="none">
    <validator type="req"/>
  </input>
  <select label="Department" name="department" type="simple">
    <option label="IT"         value="it"/>
    <option label="Finance"    value="finance"/>
    <option label="Operations" value="operations"/>
    <option label="Sales"      value="sales"/>
    <option label="HR"         value="hr"/>
    <validator type="req"/>
  </select>
  <input label="Estimated Amount" name="estimatedAmount" type="text" timeFormat="none">
    <validator type="req"/>
    <validator type="num"/>
  </input>
  <select label="Currency" name="currency" type="simple">
    <option label="EUR" value="EUR"/>
    <option label="USD" value="USD"/>
    <option label="GBP" value="GBP"/>
    <validator type="req"/>
  </select>
  <select label="Priority" name="priority" type="simple">
    <option label="Low"    value="low"/>
    <option label="Medium" value="medium"/>
    <option label="High"   value="high"/>
    <option label="Urgent" value="urgent"/>
    <validator type="req"/>
  </select>
  <input label="Required By Date" name="requiredByDate" type="date">
    <validator type="req"/>
  </input>
  <input label="Vendor / Supplier" name="vendor" type="text" timeFormat="none" />
  <textarea label="Justification / Description" name="description" type="text">
    <validator type="req"/>
  </textarea>
  <upload label="Budget Document (proforma, quote)" name="budgetDocument" data="budgetDocumentData">
    <validator type="req"/>
  </upload>
  <upload label="Other Supporting Documents" name="otherDocuments" data="otherDocumentsData" multiple="true" />
  <button label="Submit Request" name="submit" transition="submit" style="yes" color="success" validate="true" />
</workflow-form>
```

**Key points:**
- `data="poNumber"` on a `readonly` `<input>` — loads the `Input` object stored in context under key `"poNumber"` and renders its `.value`. The field name (`poNumberDisplay`) intentionally differs from the context key (`poNumber`) to avoid overwriting the `Input` object on submission.
- `data="budgetDocumentData"` — loads the `Upload` descriptor created in "Create PO folder". The field name (`budgetDocument`) differs from the context key, preventing overwrite. The uploaded file goes to the root of the PO record.
- `data="otherDocumentsData"` with `multiple="true"` — allows uploading multiple files. They go to the `others` subfolder.
- `type="date"` fields store values as `yyyyMMddHHmmss` with the time set to `000000`. Always parse with `new SimpleDateFormat("yyyyMMddHHmmss")` before reformatting.

**Transition:** `submit` → "Save metadata".

---

#### Node: Action — "Save metadata"

Reads all form values from context, writes the metadata group on the PO record, and builds reference `Input` objects for the document and folder UUIDs used in subsequent task forms.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.sdk4j.util.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

String uuid = (String) context.get("poRecordUuid");

// Read submitted form values from context
Input requestTitleInput = (Input) context.get("requestTitle");
Select departmentSelect = (Select) context.get("department");
Input estimatedAmountInput = (Input) context.get("estimatedAmount");
Select currencySelect = (Select) context.get("currency");
Select prioritySelect = (Select) context.get("priority");
Input requiredByDateInput = (Input) context.get("requiredByDate");
Input vendorInput = (Input) context.get("vendor");
TextArea descriptionTextArea = (TextArea) context.get("description");
Input poNumberInput = (Input) context.get("poNumber");

String requestTitle = requestTitleInput.getValue();
String department = departmentSelect.getValue();
String estimatedAmount = estimatedAmountInput.getValue();
String currency = currencySelect.getValue();
String priority = prioritySelect.getValue();
String requiredByDate = requiredByDateInput.getValue();
String vendor = vendorInput.getValue();
String description = descriptionTextArea.getValue();
String poNumber = poNumberInput.getValue();

// Request date captured automatically (system date, for reporting)
String requestDate = ISO8601.formatBasic(Calendar.getInstance());

// Build property map
Map<String,String> properties = new HashMap();
properties.put("okp:purchase_order.poNumber", poNumber);
properties.put("okp:purchase_order.requestTitle", requestTitle);
properties.put("okp:purchase_order.department", "[\"" + department + "\"]");
properties.put("okp:purchase_order.requestDate", requestDate);
properties.put("okp:purchase_order.estimatedAmount", estimatedAmount);
properties.put("okp:purchase_order.currency", "[\"" + currency + "\"]");
properties.put("okp:purchase_order.priority", "[\"" + priority + "\"]");
properties.put("okp:purchase_order.requiredByDate", requiredByDate);
properties.put("okp:purchase_order.vendor", vendor != null ? vendor : "");
properties.put("okp:purchase_order.description", description);
properties.put("okp:purchase_order.status", "[\"pending_budget\"]");

if (ws.propertyGroup.hasGroup(uuid, "okg:purchase_order")) {
  ws.propertyGroup.setProperties(uuid, "okg:purchase_order", properties);
} else {
  ws.propertyGroup.addGroup(uuid, "okg:purchase_order", properties);
}

FileLogger.info("purchase-order-request", "Metadata saved for " + poNumber);

// Build read-only reference fields for Budget Validation form:
// the record should contain exactly one document (the uploaded budget document)
String budgetDocumentUuid = "";
List<Document> recordDocs = ws.document.getChildren(uuid);
if (recordDocs != null && !recordDocs.isEmpty()) {
  Document firstDoc = recordDocs.get(0);
  budgetDocumentUuid = firstDoc.getUuid();
}

Input budgetDocumentUuidInput = new Input();
budgetDocumentUuidInput.setName("budgetDocumentUuid");
budgetDocumentUuidInput.setValue(budgetDocumentUuid);
context.put("budgetDocumentUuid", budgetDocumentUuidInput);

String othersFolderUuid = (String) context.get("poOthersFolderUuid");
Input othersFolderUuidInput = new Input();
othersFolderUuidInput.setName("othersFolderUuid");
othersFolderUuidInput.setValue(othersFolderUuid);
context.put("othersFolderUuid", othersFolderUuidInput);
```

**Key points:**
- `ISO8601.formatBasic(Calendar.getInstance())` requires `import com.openkm.sdk4j.util.*;`. This import is **not** included in the standard okmflow import block and must be added explicitly.
- `ws.document.getChildren(uuid)` returns `List<Document>`. Use `doc.getUuid()` directly — no need for `ws.repository.getNodeUuid(doc.getPath())`.
- `type="folder"` fields in OKMFlow forms accept any node type (document, folder, record) despite the name. They render as a clickable repository link. Storing the UUID as an `Input` object and binding via `data=` is the correct approach.
- Select field metadata values must be written as a JSON array string: `"[\"value\"]"`.

**Transition:** unnamed → "Budget Validation".

---

#### Node: Task — "Budget Validation"

**Assignment (smart re-assignment + amount-based tier):**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.okmflow.rest.dto.*;   // required for TaskInstanceDTO
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

// Check if this task has already been assigned before (re-entry after Clarification Request)
def procIns = context.get("processInstance");
long piId = procIns.getId();
List<TaskInstanceDTO> prevTasks = WorkflowUtils.getTaskInstances(piId, "Budget Validation");
if (prevTasks != null && !prevTasks.isEmpty()) {
  for (TaskInstanceDTO t : prevTasks) {
    if (t.getActor() != null && !t.getActor().isEmpty()) {
      return t.getActor();   // String → direct re-assignment (not a pool)
    }
  }
}

// First time: apply business rule based on estimated amount
Input estimatedAmountInput = (Input) context.get("estimatedAmount");
double amount = Double.parseDouble(estimatedAmountInput.getValue());

if (amount < 3000) {
  List<String> userList = new ArrayList();
  for (CommonUser commonUser : ws.auth.getUsersByRole("ROLE_FINANCE")) {
    userList.add(commonUser.getId());
  }
  return userList;   // List<String> → pool assignment
} else {
  return "financeManager";   // String → direct assignment
}
```

The form shows all request fields read-only and exposes two node-reference fields:

```xml
<input label="Budget Document"       name="budgetDocumentUuid" type="folder" readonly="true" data="budgetDocumentUuid" />
<input label="Other Documents Folder" name="othersFolderUuid"  type="folder" readonly="true" data="othersFolderUuid" />
<textarea label="Budget Reviewer Comments" name="budgetComments" type="text">
  <validator type="req"/>
</textarea>
<button label="Approve"            name="approved"            transition="approved"            style="yes"    color="success" validate="true" />
<button label="Needs Clarification" name="needs_clarification" transition="needs_clarification" style="change" color="warning" validate="true" />
<button label="Reject"             name="rejected"            transition="rejected"            confirmation="Are you sure you want to reject this request?" style="no" color="danger" validate="true" />
```

**Key points:**
- `import com.openkm.okmflow.rest.dto.*;` is required for `TaskInstanceDTO`. It is **not** included in `com.openkm.okmflow.util.*` or `com.openkm.okmflow.bean.*`.
- `WorkflowUtils.getTaskInstances(piId, "Budget Validation")` returns all historical instances of this task in the current process, ordered by id ASC. Used here to detect re-entry after a clarification loop and re-assign to the same person.
- Returning a `String` from `assignExpr` creates a direct assignment. Returning a `List<String>` creates a pool — any listed user must self-assign it. Even a single-element list behaves as a pool.
- `ws.auth.getUsersByRole("ROLE_FINANCE")` returns `List<CommonUser>`. Use `commonUser.getId()` to get the username string.

**Named transitions:**
- `approved` → "Set status - Pending Approval"
- `needs_clarification` → "Set status - Clarification"
- `rejected` → "Mail - Rejected by Budget"

---

#### Node: Action — "Set status - Clarification"

Updates `okp:purchase_order.status` to `clarification` and prepares an `Upload` object of type `update` so the Clarification Request task can replace the budget document.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

String uuid = (String) context.get("poRecordUuid");
Map<String,String> properties = new HashMap();
properties.put("okp:purchase_order.status", "[\"clarification\"]");

if (ws.propertyGroup.hasGroup(uuid, "okg:purchase_order")) {
  ws.propertyGroup.setProperties(uuid, "okg:purchase_order", properties);
}

FileLogger.info("purchase-order-request", "Status updated to clarification");

// Build an Upload object to let the user replace the budget document (new version)
Input budgetDocumentUuidInput = (Input) context.get("budgetDocumentUuid");
Upload updateBudgetUpload = new Upload();
updateBudgetUpload.setType("update");
updateBudgetUpload.setDocumentUuid(budgetDocumentUuidInput.getValue());
context.put("updateBudgetDocument", updateBudgetUpload);
```

**Key points:**
- `Upload.setType("update")` + `Upload.setDocumentUuid(uuid)` — creates a new version of an existing document rather than uploading a new file. The document UUID and path remain the same; only the content changes.
- The `Upload` descriptor is stored under context key `"updateBudgetDocument"`. In the form (see next node) the field uses `name="clarificationUpdateBudgetDocument"` with `data="updateBudgetDocument"` — different keys, as required, so the `Upload` descriptor survives the submit instead of being overwritten by the form element object.

**Transition:** unnamed → "Clarification Request".

---

#### Node: Task — "Clarification Request"

**Assignment:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Assign to the user who started the workflow
Actor actor = (Actor) context.get("initiator");
return actor.getId();
```

The requester sees the reviewer's comments and can correct all request fields. Two upload fields use different `type="update"` semantics:

```xml
<textarea label="Reviewer Comments (read)" name="budgetComments" type="text" readonly="true" data="budgetComments" />
<input label="Request Title" name="requestTitle" type="text" data="requestTitle">
  <validator type="req"/>
</input>
<!-- ... other editable fields pre-populated via data= ... -->
<input label="Budget Document" name="budgetDocumentUuid" type="folder" readonly="true" data="budgetDocumentUuid" />
<upload label="Replace Budget Document" name="clarificationUpdateBudgetDocument" data="updateBudgetDocument" type="update" />
<input label="Other Documents Folder" name="othersFolderUuid" type="folder" readonly="true" data="othersFolderUuid" />
<upload label="Other Supporting Documents" name="clarificationOtherDocuments" data="otherDocumentsData" multiple="true" type="update" />
<button label="Resubmit for Validation" name="resubmit" transition="resubmit" style="yes" color="success" validate="true" />
```

**Key points:**
- The `type="folder"` fields show the current budget document and others folder as clickable links, so the requester can review what was uploaded before deciding to replace it.
- `type="update"` on the budget upload creates a new document version in-place (same UUID, same path). The field name (`clarificationUpdateBudgetDocument`) differs from the context key (`updateBudgetDocument`), so the `Upload` descriptor survives the submit.
- `type="update"` on the others upload (`clarificationOtherDocuments`) adds new files to the `others` folder alongside existing ones — nothing is deleted. Its field name also differs from the context key (`otherDocumentsData`) for the same reason.
- All editable fields use `data="fieldName"` to pre-populate from context. When the user submits, these values overwrite the context keys with updated `Input`/`Select`/`TextArea` objects, which flow into the next Budget Validation cycle.

**Transition:** `resubmit` → "Set status - Pending Budget".

---

#### Node: Action — "Set status - Pending Budget"

Updates `okp:purchase_order.status` to `pending_budget`. Routes back to "Budget Validation".

**Script (representative — same structure for all "Set status" actions, only the status value changes):**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

String uuid = (String) context.get("poRecordUuid");
Map<String,String> properties = new HashMap();
properties.put("okp:purchase_order.status", "[\"pending_budget\"]");

if (ws.propertyGroup.hasGroup(uuid, "okg:purchase_order")) {
  ws.propertyGroup.setProperties(uuid, "okg:purchase_order", properties);
}

FileLogger.info("purchase-order-request", "Status updated to pending_budget");
```

The same structure (only the literal status string changes) is reused for "Set status - Pending Approval" (`pending_approval`) and "Set status - Approved" (`approved`).

**Transition:** unnamed → "Budget Validation".

---

#### Node: Action — "Set status - Pending Approval"

Updates `okp:purchase_order.status` to `pending_approval`.

**Transition:** unnamed → "Manager Approval".

---

#### Node: Task — "Manager Approval"

**Assignment (department-based):**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;

// Assign according to requester's department
Select departmentSelect = (Select) context.get("department");
String department = departmentSelect.getValue();

if ("it".equals(department)) {
  return "itManager";
} else if ("finance".equals(department)) {
  return "financeManager";
} else if ("operations".equals(department)) {
  return "operationsManager";
} else if ("sales".equals(department)) {
  return "salesManager";
} else if ("hr".equals(department)) {
  return "hrManager";
} else {
  // Provisional fallback until specific roles are defined
  return "okmAdmin";
}
```

The form shows the full request read-only (including budget reviewer comments and both document reference fields), plus a required manager comments textarea.

**Key points:**
- Department value comes from the `Select` object in context. Use `.getValue()` to get the string.
- To add a department or change a manager mapping, edit only the `assignExpr` of this node.
- `type="folder"` fields (`budgetDocumentUuid`, `othersFolderUuid`) give the manager direct access to the supporting documents from within the task form.

**Named transitions:**
- `approved` → "Set status - Approved"
- `rejected` → "Mail - Rejected by Manager"

---

#### Node: Action — "Set status - Approved"

Updates `okp:purchase_order.status` to `approved`.

**Transition:** unnamed → "Purchase Order".

---

#### Node: Task — "Purchase Order"

**Assignment (same actor as Budget Validation):**
```groovy
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.rest.dto.*;   // required for TaskInstanceDTO
import java.util.*;

// Assign to the same actor who handled Budget Validation
def procIns = context.get("processInstance");
long piId = procIns.getId();
List<TaskInstanceDTO> prevTasks = WorkflowUtils.getTaskInstances(piId, "Budget Validation");
if (prevTasks != null && !prevTasks.isEmpty()) {
  for (TaskInstanceDTO t : prevTasks) {
    if (t.getActor() != null && !t.getActor().isEmpty()) {
      return t.getActor();
    }
  }
}

// Fallback if no previous actor is found (should not normally happen)
return "okmAdmin";
```

The form shows a read-only summary followed by editable purchase order fields separated by a `<separator>`:

```xml
<separator label="Purchase Order Details" name="poDetailsSeparator" />
<input label="PO Number (official)" name="poOfficialNumber" type="text" timeFormat="none">
  <validator type="req"/>
</input>
<input label="Actual Amount" name="actualAmount" type="text" timeFormat="none">
  <validator type="req"/>
  <validator type="num"/>
</input>
<input label="Vendor Confirmed"     name="vendorConfirmed" type="text" timeFormat="none">
  <validator type="req"/>
</input>
<input label="Expected Delivery Date" name="deliveryDate" type="date">
  <validator type="req"/>
</input>
<textarea label="PO Notes" name="poNotes" type="text" />
<button label="Issue Purchase Order" name="issued" transition="issued" style="yes" color="success" validate="true" />
```

**Key point:**
- `<separator>` requires **both** `label` and `name` attributes. Omitting either causes a `ParseException` at load time. It stores no data.

**Transition:** `issued` → "Set status - Issued".

---

#### Node: Action — "Set status - Issued"

Updates `okp:purchase_order.status` to `issued`. Also converts the `deliveryDate` field value (stored as `yyyyMMddHHmmss`) to `yyyy-MM-dd` for the notification email.

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;
import java.text.*;

OKMWebservices ws = WebservicesHelper.getInstance();

String uuid = (String) context.get("poRecordUuid");
Map<String,String> properties = new HashMap();
properties.put("okp:purchase_order.status", "[\"issued\"]");

if (ws.propertyGroup.hasGroup(uuid, "okg:purchase_order")) {
  ws.propertyGroup.setProperties(uuid, "okg:purchase_order", properties);
}

FileLogger.info("purchase-order-request", "Status updated to issued");

// Format delivery date (stored as yyyyMMddHHmmss) to yyyy-MM-dd for the Mail node
SimpleDateFormat raw = new SimpleDateFormat("yyyyMMddHHmmss");
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");

Input deliveryDateRaw = (Input) context.get("deliveryDate");
Input deliveryDateFmt = new Input();
deliveryDateFmt.setValue(fmt.format(raw.parse(deliveryDateRaw.getValue())));
context.put("deliveryDate_fmt", deliveryDateFmt);
```

**Key points:**
- `type="date"` fields store values as `yyyyMMddHHmmss`. Always reformat before including in emails or displaying to users.
- A new `Input` object is created for the formatted value (`deliveryDate_fmt`). The Mail node accesses it as `${deliveryDate_fmt.value}` (FreeMarker syntax).
- Do **not** overwrite `deliveryDate` — the original value may be needed for metadata writes in other nodes.

**Transition:** unnamed → "Mail - PO Issued".

---

#### Node: Mail — "Mail - Rejected by Budget"

| Field | Value |
|---|---|
| Recipients | `${initiator.email}` |
| Subject | `Purchase Order ${poNumber.value} - Rejected at Budget Validation` |
| Body | Includes `${poNumber.value}` and `${budgetComments.value}`. |

**Transition:** unnamed → End.

---

#### Node: Mail — "Mail - Rejected by Manager"

| Field | Value |
|---|---|
| Recipients | `${initiator.email}` |
| Subject | `Purchase Order ${poNumber.value} - Rejected by Manager` |
| Body | Includes `${poNumber.value}` and `${managerComments.value}`. |

**Transition:** unnamed → End.

---

#### Node: Mail — "Mail - PO Issued"

| Field | Value |
|---|---|
| Recipients | `${initiator.email}` |
| Subject | `Purchase Order ${poNumber.value} - Approved and Issued` |
| Body | Includes `${poOfficialNumber.value}`, `${vendorConfirmed.value}`, `${deliveryDate_fmt.value}`. |

**Key point:** `${poNumber.value}` — context variables that are form element objects (not plain strings) require the `.value` suffix in Mail node FreeMarker templates. Plain strings use `${key}` directly.

**Transition:** unnamed → End.

---

#### Node: End — `workflow.end`

Standard end node. Single convergence point for all terminal paths.

---

### Patterns illustrated in this example

| Pattern | Where applied |
|---|---|
| Sequential auto-increment counter via custom table | `APP_COUNTER` (`scripts.sql`) + `ws.repository.executeSqlQuery()` in "Create PO folder" — see "Pattern — Sequential Counters" |
| Create a record programmatically | `ws.record.create(parentUuid, name, "", 0)` — note: `createMissingRecords()` does not exist |
| Create a subfolder inside a record | `ws.folder.create(recordUuid, "others")` → `fld.getUuid()` |
| Attach workflow process instance to a repository node | `WorkflowUtils.setProcessInstanceNode(context, uuid)` — first param is `context`, not `piId` |
| `Upload` descriptor naming to prevent context overwrite | `name="budgetDocument"` + `data="budgetDocumentData"` in "Request Submission"; `name="clarificationUpdateBudgetDocument"` + `data="updateBudgetDocument"` in "Clarification Request" — field name always differs from the context key |
| `type="update"` upload — replace document in-place | `Upload.setType("update")` + `Upload.setDocumentUuid(uuid)` in Action; `type="update"` on `<upload>` in form |
| `type="folder"` for any node reference (doc, folder, record) | Read-only `<input type="folder" data="budgetDocumentUuid" />` in Budget Validation, Clarification Request, Manager Approval, Purchase Order |
| Pool → direct re-assignment via task history | `WorkflowUtils.getTaskInstances(piId, taskName)` + return `String` (not `List`) on re-entry |
| Amount-based assignment tier | `estimatedAmount < 3000` → `ROLE_FINANCE` pool; `≥ 3000` → `financeManager` direct |
| Department-based manager assignment | `Select.getValue()` + if/else chain in `assignExpr` of "Manager Approval" |
| Cross-task assignment via task history | "Purchase Order" reads actor from "Budget Validation" history via `getTaskInstances` |
| `ISO8601.formatBasic()` — requires explicit import | `import com.openkm.sdk4j.util.*;` — not included in the standard okmflow import block |
| `TaskInstanceDTO` — requires explicit import | `import com.openkm.okmflow.rest.dto.*;` — not in `okmflow.util.*` or `okmflow.bean.*` |
| Date reformatting before Mail nodes | Parse `yyyyMMddHHmmss` with `SimpleDateFormat`, store as new `Input`, reference as `${key.value}` in template |
| `<separator>` requires both `label` and `name` | `<separator label="Title" name="uniqueName" />` — both attributes mandatory; omitting either causes `ParseException` |
| Metadata group written by Action nodes only | Group `okg:purchase_order` is `readonly="true"`; all writes via `ws.propertyGroup.setProperties()` |
| `Document.getUuid()` — direct access | `ws.document.getChildren(uuid).get(0).getUuid()` — no need for `ws.repository.getNodeUuid(doc.getPath())` |

## Template — Expense Report Approval

**Folder:** `expense-report-approval/`
**Process file:** `expense-report-approval/expense-report-approval.okmflow`
**Metadata:** `expense-report-approval/metadata.xml`
**SQL:** `expense-report-approval/script.sql`

### Description

An expense report approval workflow that goes beyond a simple linear approval chain. The employee submits a receipt with expense details, a manager approves or rejects it, Finance validates it against budget, and Payment Processing closes the loop. It illustrates three patterns not covered by the Vacation Request or Purchase Order templates:

1. **Cross-task re-assignment to a non-adjacent task's actor** — when Finance rejects a request, it is *not* routed back to the employee or ended; it is routed to a brand-new task (`Manager Review`) assigned to whoever completed **Manager Approval**, several nodes earlier, via `WorkflowUtils.getTaskInstances(piId, "Manager Approval")`.
2. **Multiple task nodes converging on one shared Action node** — two different tasks (`Manager Approval` and `Manager Review`) both have a `clarification` transition, and both target the *same* `Set status - Clarification Requested` action and the same downstream `Mail` node, rather than each having its own copy of the metadata-write logic.
3. **Two independently-tracked terminal rejection branches with the same shape** — a direct manager rejection and a Finance-then-manager rejection produce the same user-facing outcome (employee notified, process ends) but are deliberately implemented as two separate Action → Mail → End node triples so their `status` metadata values can differ for reporting.

Like Purchase Order, this workflow does **not** start on an existing OpenKM node — it creates its own record at startup and attaches the process instance to it via `WorkflowUtils.setProcessInstanceNode(context, recordUuid)`.

**Prerequisites:**

| Requirement | Description |
|-------------|-------------|
| `APP_COUNTER` table (`script.sql`) | Generic sequential-counter table shared across workflows (see "Pattern — Sequential Counters" above). If Purchase Order is already deployed on the same environment, the table already exists — only the `INSERT` row for `expense_report` is needed. |
| Role `ROLE_FINANCE` | Pool for the first pass of Finance Validation. Must have at least one user assigned. |
| Users `itManager`, `financeManager`, `operationsManager`, `salesManager`, `hrManager` | Direct assignment for Manager Approval, one per department. |
| User `paymentManager` | Direct assignment for Payment Processing. |
| Metadata group `okg:expense_report` (`metadata.xml`) | `readonly="true"` — written exclusively by Action nodes, never by the user. Fields: `expenseNumber`, `employee`, `department`, `expenseDate`, `category`, `amount`, `currency`, `description`, `managerComments`, `financeComments`, `paymentDate`, `paymentReference`, `status`. |

**SQL setup (`script.sql`, run once per environment):**
```sql
-- Reuses the generic APP_COUNTER table (create it only if it doesn't already exist,
-- e.g. if you deployed the Purchase Order workflow first)
CREATE TABLE IF NOT EXISTS APP_COUNTER (
    counter_key   VARCHAR(50)  NOT NULL,
    counter_value INT          NOT NULL DEFAULT 1,
    CONSTRAINT pk_app_counter PRIMARY KEY (counter_key)
);

INSERT INTO APP_COUNTER (counter_key, counter_value) VALUES ('expense_report', 1);
```

### Repository structure created at startup

```
/okm:root/
└── Expense Report Approval/
    └── {year}/                              ← folder (ws.folder.createMissingFolders)
        └── RECEIPTS-{year}-{NNNNN}/         ← record (ws.record.create) — documents go directly at the record root
```

**Key point — no subfolder needed:** the Purchase Order sample creates an `others` subfolder to separate document categories. Here every uploaded document is a receipt, so the `Upload` descriptor points directly at the record root (`receiptsUpload.setFolderUuid(expenseRecordUuid)`). This is a simpler, valid alternative when a workflow only ever handles one document category.

### Node flow

```
Start → [Action] Create Expense Record ──► [Task] Expense Submission ──(submit)──► [Action] Save Expense Metadata
                                                                                          │
                                                                                          ▼
                                                                              [Task] Manager Approval
                                              ┌───────────────(approved)──────────────────┼──────────(rejected)──────────┐
                                              ▼                                            ▼                              ▼
                              [Action] Set status - Pending Finance          [Action] Set status - Clarification    [Action] Set status -
                                              │                                Requested (SHARED)                    Rejected by manager
                                              ▼                                            │                              │
                                    [Task] Finance Validation                              ▼                              ▼
                          ┌──────(approved)─────────┴──(rejected)──┐          [Mail] Clarification Requested   [Mail] Final Rejection
                          ▼                                        ▼                       │                        by manager
              [Action] Set status - Approved              [Task] Manager Review            ▼                              │
                          │                    (re-assigned to Manager Approval's actor)  [Task] Employee Edit             ▼
                          ▼                    ┌──────────┼──────────┐        & Resubmit ──(resubmit)──►      [End] End by manager
              [Task] Payment Processing        │          │          │          [Action] Set status - Pending Manager
                          │             (clarification)(rejected)(replyFinance)         │
                          ▼                    │          │          │                  ▼
              [Action] Set status - Paid       │          ▼          ▼        (back to Manager Approval)
                          │                     │  [Action] Set    [Action] Set status -
                          ▼                     │   status -        Pending Finance
              [Mail] Payment Completed          │   Rejected by     (Manager Reply)
                          │                     │   finance and              │
                          ▼                     │   manager                  ▼
                        [End]                   │          │       (back to Finance Validation,
                                                 │          ▼        re-assigned to same actor)
                                                 │  [Mail] Final Rejection
                                                 │   by finance and manager
                                                 │          │
                                                 │          ▼
                                                 │  [End] End by finance and manager
                                                 │
                                                 └──► (same shared path as Manager Approval's
                                                       "clarification" transition, above)
```

---

#### Node: Start — `workflow.start`

Standard start node. This workflow starts **without an associated OpenKM node** — `uuid` and `node` are not present in the initial context. Unnamed transition → "Create Expense Record".

---

#### Node: Action — "Create Expense Record"

Runs before any task is shown, so the upload target folder exists when **Expense Submission** renders. Reads/increments the sequential counter from `APP_COUNTER` with `counter_key = 'expense_report'` (see "Pattern — Sequential Counters"):

```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.Calendar;

OKMWebservices ws = WebservicesHelper.getInstance();

SqlQueryResults counterResult = ws.repository.executeSqlQuery(
    "SELECT counter_value FROM APP_COUNTER WHERE counter_key = 'expense_report'");
int currentValue = Integer.parseInt(counterResult.getResults().get(0).getColumns().get(0));
int nextValue = currentValue + 1;
ws.repository.executeSqlQuery(
    "UPDATE APP_COUNTER SET counter_value = " + nextValue + " WHERE counter_key = 'expense_report'");

int year = Calendar.getInstance().get(Calendar.YEAR);
String expenseNumber = String.format("RECEIPTS-%d-%05d", year, currentValue);

String yearFolderPath = "/okm:root/Expense Report Approval/" + year;
if (!ws.repository.hasNode(yearFolderPath)) {
  ws.folder.createMissingFolders(yearFolderPath);
}
String yearFolderUuid = ws.repository.getNodeUuid(yearFolderPath);

Record expenseRecord = ws.record.create(yearFolderUuid, expenseNumber, "", 0);
String expenseRecordUuid = expenseRecord.getUuid();

Input expenseNumberInput = new Input();
expenseNumberInput.setValue(expenseNumber);
context.put("expenseNumber", expenseNumberInput);
context.put("expenseRecordUuid", expenseRecordUuid);

Upload receiptsUpload = new Upload();
receiptsUpload.setFolderUuid(expenseRecordUuid);   // record root, not a subfolder
context.put("receiptsUploadData", receiptsUpload);

WorkflowUtils.setProcessInstanceNode(context, expenseRecordUuid);
```

**Key point:** `expenseNumber` uses a 5-digit sequence (`%05d`) instead of Purchase Order's 3-digit sequence (`%03d`) — the padding width is a per-workflow convention, not a framework constraint.

**Transition:** unnamed → "Expense Submission".

---

#### Node: Task — "Expense Submission"

**Assignment:** assigned to the workflow initiator (`context.get("initiator")`).

**Form:**
```xml
<workflow-form>
  <text label="Fill in the details of your expense and attach your receipts (invoices, tickets, boarding passes, etc.) below." name="instructions" width="100%"/>
  <select label="Department" name="department" type="simple">...</select>
  <input label="Expense Date" name="expenseDate" type="date" timeFormat="none">
    <validator type="req"/>
  </input>
  <select label="Category" name="category" type="simple">...</select>
  <input label="Amount" name="amount" type="text">
    <validator type="req"/>
    <validator type="dec"/>
    <validator type="gt" parameter="0"/>
  </input>
  <select label="Currency" name="currency" type="simple">...</select>
  <textarea label="Description" name="description" type="text">
    <validator type="req"/>
  </textarea>
  <upload label="Receipts" name="receipts" data="receiptsUploadData" multiple="true" allowedExtensions="pdf;jpg;jpeg;png"/>
  <button name="submit" label="Submit" transition="submit" color="success"/>
</workflow-form>
```

**Key point:** `<upload name="receipts" data="receiptsUploadData">` — the field `name` differs from the `data` context key, same mandatory pattern as Purchase Order's uploads (see that template's "Key points" for "Request Submission"): if they were equal, the engine would overwrite the `Upload` descriptor (with its `folderUuid`) with the plain form element object on submit.

**Transition:** `submit` → "Save Expense Metadata".

---

#### Node: Action — "Save Expense Metadata"

Writes the metadata group for the first time (`addGroup`) or updates it if it already exists (`setProperties`) — this same node is also the re-entry point after **Employee Edit & Resubmit** loops back through **Manager Approval**, so it defensively checks `ws.propertyGroup.hasGroup(...)` rather than assuming it always runs exactly once:

```groovy
OKMWebservices ws = WebservicesHelper.getInstance();

String expenseRecordUuid = (String) context.get("expenseRecordUuid");
Input expenseNumberInput = (Input) context.get("expenseNumber");
String expenseNumber = expenseNumberInput.getValue();

// Employee: the workflow initiator
Actor initiator = context.get("initiator");
String employeeName = initiator.getName();
Input employeeInput = new Input();
employeeInput.setValue(employeeName);
context.put("employee", employeeInput);

Select departmentSelect = (Select) context.get("department");
Input expenseDateInput = (Input) context.get("expenseDate");
Select categorySelect = (Select) context.get("category");
Input amountInput = (Input) context.get("amount");
Select currencySelect = (Select) context.get("currency");
TextArea descriptionTextArea = (TextArea) context.get("description");

Map<String, String> properties = new HashMap();
properties.put("okp:expense_report.expenseNumber", expenseNumber);
properties.put("okp:expense_report.employee", employeeName);
properties.put("okp:expense_report.department", "[\"" + departmentSelect.getValue() + "\"]");
properties.put("okp:expense_report.expenseDate", expenseDateInput.getValue());
properties.put("okp:expense_report.category", "[\"" + categorySelect.getValue() + "\"]");
properties.put("okp:expense_report.amount", amountInput.getValue());
properties.put("okp:expense_report.currency", "[\"" + currencySelect.getValue() + "\"]");
properties.put("okp:expense_report.description", descriptionTextArea.getValue());
properties.put("okp:expense_report.status", "[\"pending_manager_approval\"]");

if (!ws.propertyGroup.hasGroup(expenseRecordUuid, "okg:expense_report")) {
  ws.propertyGroup.addGroup(expenseRecordUuid, "okg:expense_report", properties);
} else {
  ws.propertyGroup.setProperties(expenseRecordUuid, "okg:expense_report", properties);
}
```

**Key points:**
- `okp:expense_report.employee` is derived from `context.get("initiator")` (the actor who started the process), not from a form field — the employee never types their own name.
- `Select` values are written as a JSON-style single-element array string (`"[\"it\"]"`), matching the format `ws.propertyGroup.setProperties` expects for `type="simple"` select properties.

**Transition:** unnamed → "Manager Approval".

---

#### Node: Task — "Manager Approval" (assignExpr)

Straightforward department → user map, included here mainly as the baseline that **Manager Review** (below) deliberately does *not* reuse:

```groovy
Select departmentSelect = (Select) context.get("department");
String department = departmentSelect.getValue();

switch (department) {
  case "it": return "itManager";
  case "finance": return "financeManager";
  case "operations": return "operationsManager";
  case "sales": return "salesManager";
  case "hr": return "hrManager";
  default: return "okmAdmin";
}
```

Its form has a single `Manager Comments` textarea reused across all three of its outcomes:

```xml
<textarea label="Manager Comments" name="managerComments"/>
<button name="approve" label="Approve" transition="approved" color="success"/>
<button name="clarify" label="Request Clarification" transition="clarification" color="warning"/>
<button name="reject" label="Reject" transition="rejected" color="danger"/>
```

**Key point — one comment field for every outcome:** `managerComments` is read by three different downstream Action nodes (`Set status - Pending Finance`, `Set status - Clarification Requested`, `Set status - Rejected by manager`), each casting `context.get("managerComments")` to `TextArea` and guarding against `null` (the field is optional, so an approval with no comment must not throw a `NullPointerException`):
```groovy
TextArea managerCommentsTextArea = (TextArea) context.get("managerComments");
String managerComments = managerCommentsTextArea != null ? managerCommentsTextArea.getValue() : "";
```

**Transitions:** `approved` → "Set status - Pending Finance"; `clarification` → "Set status - Clarification Requested"; `rejected` → "Set status - Rejected by manager".

---

#### Node: Action — "Set status - Pending Finance"

Sets `status` to `pending_finance_validation` and persists `managerComments`. Structurally identical to "Set status - Approved" and "Set status - Rejected by manager" below — the only difference across all three is the status value written and the target metadata field (`managerComments` vs. `financeComments`).

**Transition:** unnamed → "Finance Validation".

---

#### Node: Task — "Finance Validation" (assignExpr — pool → direct re-assignment)

```groovy
def procIns = context.get("processInstance");
long piId = procIns.getId();
List<TaskInstanceDTO> prevTasks = WorkflowUtils.getTaskInstances(piId, "Finance Validation");

if (prevTasks != null && !prevTasks.isEmpty()) {
  // Walk backwards: if this task has already been handled once, re-assign directly to the same user
  for (int i = prevTasks.size() - 1; i >= 0; i--) {
    TaskInstanceDTO t = prevTasks.get(i);
    if (t.getActor() != null && !t.getActor().isEmpty()) {
      return t.getActor(); // Direct re-assignment — return String, not List
    }
  }
}

// First time: pool of all ROLE_FINANCE users, any of them can self-assign
OKMWebservices ws = WebservicesHelper.getInstance();
List<String> users = new ArrayList();
for (CommonUser commonUser : ws.auth.getUsersByRole("ROLE_FINANCE")) {
  users.add(commonUser.getId());
}
return users;
```

**Key point:** this is the *same-task re-entry* variant of the task-history lookup pattern — `getTaskInstances(piId, "Finance Validation")` looks up **its own** name. It re-enters via `Set status - Pending Finance (Manager Reply)` (Manager Review's `replyFinance` branch), and on that second pass must land back on the same finance user who validated it the first time, not a fresh pool pick. Compare with **Manager Review**'s assignExpr below, which looks up a *different* task's history.

Its form shows a read-only summary of the request plus, if it was previously routed back by the manager, a read-only `Manager's Reply` field bound to `data="managerComments"`.

**Transitions:** `approved` → "Set status - Approved"; `rejected` → "Manager Review".

---

#### Node: Task — "Manager Review" (the cross-task re-assignment pattern)

Reached only via **Finance Validation**'s `rejected` transition. Its `assignExpr` does **not** repeat the department-mapping logic from Manager Approval. Instead it looks up who actually ran that task, so the same person continues to own the request:

```groovy
def procIns = context.get("processInstance");
long piId = procIns.getId();
List<TaskInstanceDTO> prevTasks = WorkflowUtils.getTaskInstances(piId, "Manager Approval");

if (prevTasks != null && !prevTasks.isEmpty()) {
  for (int i = prevTasks.size() - 1; i >= 0; i--) {
    TaskInstanceDTO t = prevTasks.get(i);
    if (t.getActor() != null && !t.getActor().isEmpty()) {
      return t.getActor(); // direct assignment — String, not List
    }
  }
}

return "okmAdmin"; // fallback, should not normally occur
```

**Key point:** `getTaskInstances(piId, taskName)` is not limited to re-entry into the *same* task (as in Finance Validation's own re-assignment above, or Example 07's clarification loop) — it can look up the actor of **any prior task by name**, anywhere earlier in the graph, and hand a completely different task to that same person. This is the pattern to reach for whenever "whoever handled step N should also handle step M" is a business requirement, even when N and M are not adjacent and not the same task definition.

Its form has three outcomes, each with a different downstream shape:

```xml
<button name="clarify" label="Request Employee Clarification" transition="clarification" color="warning"/>
<button name="reject" label="Reject" transition="rejected" color="danger"/>
<button name="replyFinance" label="Reply to Finance" transition="replyFinance" color="primary"/>
```

- `clarification` → targets the **same** action node that Manager Approval's own `clarification` transition targets (see next section).
- `rejected` → targets an **independent** Action/Mail/End triple, not the one used by Manager Approval's direct `rejected` transition.
- `replyFinance` → targets a dedicated `Set status - Pending Finance (Manager Reply)` action that loops back into **Finance Validation**, which re-assigns to the same finance user via its own task-history check.

---

#### Node: Action — "Set status - Clarification Requested" (fan-in / shared downstream node pattern)

Two different transitions, from two different task nodes, both target this single action:

```
Manager Approval  --(clarification)--┐
                                       ├──► Set status - Clarification Requested ──► Mail - Clarification Requested ──► Employee Edit & Resubmit
Manager Review    --(clarification)--┘
```

```groovy
OKMWebservices ws = WebservicesHelper.getInstance();

String expenseRecordUuid = (String) context.get("expenseRecordUuid");
TextArea managerCommentsTextArea = (TextArea) context.get("managerComments");
String managerComments = managerCommentsTextArea != null ? managerCommentsTextArea.getValue() : "";

Map<String, String> properties = new HashMap();
properties.put("okp:expense_report.status", "[\"clarification_requested\"]");
properties.put("okp:expense_report.managerComments", managerComments);

ws.propertyGroup.setProperties(expenseRecordUuid, "okg:expense_report", properties);
```

**Key point:** an Action node's incoming edges are not restricted to a single source. As long as every possible caller has already placed the fields the action reads (`managerComments`, in both cases, since both forms use that exact field name) into context under the same key, one action can safely serve multiple task nodes. This avoids duplicating the metadata-write Groovy in two places that would otherwise drift out of sync.

**Transition:** unnamed → "Mail - Clarification Requested".

---

#### Node: Mail — "Mail - Clarification Requested"

| Field | Value |
|---|---|
| Recipients | `${initiator.email}` |
| Subject | `Clarification requested for expense report ${expenseNumber.value}` |
| Body | Includes `${expenseNumber.value}` and `${managerComments.value}`. |

**Transition:** unnamed → "Employee Edit & Resubmit".

---

#### Node: Task — "Employee Edit & Resubmit" (full pre-filled resubmission)

Every field uses the **same `name`** as the original **Expense Submission** form, plus a `data="..."` pointing at the same context key, so the field is both pre-filled and — on submit — naturally overwrites the original value:

```xml
<select label="Department" name="department" type="simple" data="department">...</select>
<input label="Expense Date" name="expenseDate" type="date" timeFormat="none" data="expenseDate">
  <validator type="req"/>
</input>
...
<upload label="Additional Receipts" name="additionalReceipts" data="receiptsUploadData" multiple="true" allowedExtensions="pdf;jpg;jpeg;png"/>
<button name="resubmit" label="Resubmit" transition="resubmit" color="success"/>
```

**Key point — reusing a `name` across two different task forms is intentional and safe here:** the "same name overwrites context" rule (see the OKMFlow context variables reference) is exactly what makes this pattern work across *two* forms in sequence — Expense Submission writes `department` once, Employee Edit & Resubmit reads it back via `data="department"` and, on its own submit, writes a (possibly different) value to the same key. The downstream action (`Set status - Pending Manager`) then reads the latest value regardless of which task last wrote it. Note also the upload field: `name="additionalReceipts"` differs from `data="receiptsUploadData"`, for the same overwrite-prevention reason as in "Expense Submission".

**Transition:** `resubmit` → "Set status - Pending Manager".

---

#### Node: Action — "Set status - Pending Manager"

Re-reads every editable field (department, expenseDate, category, amount, currency, description) from the resubmission form and writes them back to `okg:expense_report`, then sets `status` to `pending_manager_approval`. Loops back to **Manager Approval**, which re-evaluates its department → user `assignExpr` against the (possibly changed) department value.

**Transition:** unnamed → "Manager Approval".

---

#### Node: Action — "Set status - Approved"

Sets `status` to `approved` and persists `financeComments` (same null-guard pattern as `managerComments`).

**Transition:** unnamed → "Payment Processing".

---

#### Node: Task — "Payment Processing"

**Assignment:** fixed direct assignment, `return "paymentManager";` — no department or history logic needed, since payment is always handled by the same role regardless of who requested or approved the expense.

Form shows a read-only summary plus two required inputs, `Payment Date` and `Payment Reference`.

**Transition:** `paid` → "Set status - Paid".

---

#### Node: Action — "Set status - Paid" (raw vs. formatted date)

```groovy
String expenseRecordUuid = (String) context.get("expenseRecordUuid");
Input paymentDateInput = (Input) context.get("paymentDate");
Input paymentReferenceInput = (Input) context.get("paymentReference");

Map<String, String> properties = new HashMap();
properties.put("okp:expense_report.status", "[\"paid\"]");
properties.put("okp:expense_report.paymentDate", paymentDateInput.getValue()); // RAW yyyyMMddHHmmss
properties.put("okp:expense_report.paymentReference", paymentReferenceInput.getValue());

ws.propertyGroup.setProperties(expenseRecordUuid, "okg:expense_report", properties);

SimpleDateFormat srcFmt = new SimpleDateFormat("yyyyMMddHHmmss");
SimpleDateFormat dstFmt = new SimpleDateFormat("yyyy-MM-dd");
String paymentDateFormatted = dstFmt.format(srcFmt.parse(paymentDateInput.getValue()));
Input paymentDateFmt = new Input();
paymentDateFmt.setValue(paymentDateFormatted);
context.put("paymentDate_fmt", paymentDateFmt); // FORMATTED, for the Mail node only
```

**Key point:** this mirrors the Purchase Order sample's `deliveryDate_fmt` pattern — the *raw* `yyyyMMddHHmmss` value is what must go into `properties.put(...)`; reformatting the value passed to `setProperties`/`addGroup` throws a validation error at runtime. Only the *copy* stored under a differently-named context key (`paymentDate_fmt`) should ever be reformatted, and only for FreeMarker display in a Mail node.

**Transition:** unnamed → "Mail - Payment Completed".

---

#### Node: Mail — "Mail - Payment Completed"

| Field | Value |
|---|---|
| Recipients | `${initiator.email}` |
| Subject | `Your expense report ${expenseNumber.value} has been paid` |
| Body | Includes `${expenseNumber.value}`, `${paymentDate_fmt.value}`, `${paymentReference.value}`. |

**Transition:** unnamed → End (`workflow.end`).

---

#### Node: Action — "Set status - Rejected by manager" / "Set status - Rejected by finance and manager"

Two independent Action nodes with the same shape (write `status` + `managerComments`, log, transition to their own Mail node) but different `status` values reached from different paths — the first from Manager Approval's direct `rejected` transition, the second from Manager Review's `rejected` transition (after Finance already rejected once). Each feeds its own Mail node (`Mail - Final Rejection by manager` / `Mail - Final Rejection by finance and manager`) and its own End node (`End by manager` / `End by finance and manager`).

**Key point:** see "Node: End — three separate End nodes" below for why these are not collapsed into one branch.

---

#### Node: End — three separate End nodes

Unlike most single-flow examples that converge on one `End`, this workflow has **three**: `workflow.end` (successful payment), `End by manager` (direct rejection), and `End by finance and manager` (rejection that passed through Finance). Multiple End nodes are valid; use them when distinct terminal outcomes should remain visually and semantically separate on the diagram, even if nothing downstream depends on which End node was reached.

### Patterns illustrated in this example

| Pattern | Where applied |
| --- | --- |
| Cross-task re-assignment via task history (non-adjacent tasks) | `Manager Review`'s `assignExpr` looks up `Manager Approval`'s actor |
| Pool → direct re-assignment via task history (same task, re-entry) | `Finance Validation`'s `assignExpr` |
| Fan-in: multiple tasks sharing one downstream Action | `Set status - Clarification Requested`, targeted by both `Manager Approval` and `Manager Review` |
| Independent terminal branches for the same conceptual outcome | `Set status - Rejected by manager` vs. `Set status - Rejected by finance and manager` |
| Cross-form field reuse for pre-fill + overwrite | `Employee Edit & Resubmit` reusing `Expense Submission`'s field names |
| Record root as the only upload target (no subfolder) | `Create Expense Record` |
| Raw vs. formatted date value split | `Set status - Paid` (`paymentDate` vs. `paymentDate_fmt`) |
| Single shared comment field for every task outcome | `managerComments` in `Manager Approval` and `Manager Review` |
| Multiple End nodes for distinct terminal outcomes | `workflow.end`, `End by manager`, `End by finance and manager` |
| `addGroup` vs `setProperties` guarded by `hasGroup` | `Save Expense Metadata` — first write uses `addGroup`, later re-entries use `setProperties` |
| Sequential auto-increment counter shared across workflows | `APP_COUNTER` with `counter_key = 'expense_report'`, same table as Purchase Order's `counter_key = 'purchase_order'` |

---

## Template — Complaint Management

**Folder:** `complaint-management/`
**Process file:** `complaint-management/complaint-management.okmflow`
**Metadata:** `complaint-management/metadata.xml`
**SQL:** `complaint-management/script.sql`

### Description

Full customer complaint / claim management workflow. Unlike Purchase Order and Expense Report Approval, this workflow does not collect its initial data via a Task after Start — it uses the reserved `run_config` pre-launch form to collect the claimant's contact details and complaint description *before* the process instance exists, then the first Action node creates the case record directly from that data. It then goes through manual classification, manual assignment (dispatcher picks the assignee, not role-based auto-assignment), investigation with an information-request loop back to the claimant, and resolution — with no separate approval step: whoever investigates also resolves.

This workflow does **not** start on an existing OpenKM node. It creates its own repository structure (a record) at startup and attaches the process instance to it via `WorkflowUtils.setProcessInstanceNode(context, recordUuid)`.

**Prerequisites:**

| Requirement | Description |
|-------------|-------------|
| `APP_COUNTER` table (`script.sql`) | Generic sequential-counter table shared across workflows (see "Pattern — Sequential Counters" above). Must exist with a `complaint` row before the first execution. |
| Role `ROLE_CLAIMS_TRIAGE` | Pool for the Classification task. Must have at least one user assigned. |
| Role `ROLE_CLAIMS_COORDINATOR` | Pool for the Assignment task (manual dispatch — the coordinator picks any OpenKM user as assignee via `OptionSelectUserList`, not a fixed role member). |
| Metadata group `okg:complaint` (`metadata.xml`) | `readonly="true"` — written exclusively by Action nodes, never by the user directly. Fields: `contact_name`, `contact_email`, `contact_phone`, `claim_description`, `case_number`, `claim_type`, `priority`, `department`, `assigned_to`, `investigation_findings`, `resolution_type`, `closed_date`, `status`. |

**SQL setup (`script.sql`, run once per environment):**
```sql
CREATE TABLE IF NOT EXISTS APP_COUNTER (
    counter_key   VARCHAR(50) NOT NULL,
    counter_value INT         NOT NULL DEFAULT 1,
    CONSTRAINT pk_app_counter PRIMARY KEY (counter_key)
);
INSERT INTO APP_COUNTER (counter_key, counter_value) VALUES ('complaint', 1);
```

> If another template that also relies on `APP_COUNTER` (e.g. `purchase-order`, `expense-report-approval`) is deployed on the same environment, run only one `CREATE TABLE` statement for the shared table and add the remaining `INSERT` rows — the table itself must not be created twice.

### Repository structure created at startup

```
/okm:root/
└── Complaints/
    └── {year}/                    ← folder (ws.folder.createMissingFolders)
        └── CLM-{year}-{NNN}/      ← record (ws.record.create)
            └── resolution/        ← folder (ws.folder.createMissingFolders) — for the optional resolution document
```

### Node flow

```
run_config (pre-workflow launch form — not connected to the diagram)
  ↓ (implicit, engine-managed)
Start
  ↓
[Action] Generate Claim Reference and Initial Data
  ↓
[Mail] Acknowledgement of Receipt
  ↓
[Task] Classification
  ↓ (classify)
[Task] Assignment
  ↓ (assign)
[Action] Save Classification and Assignment
  ↓
[Task] Investigation ←──────────────────────────────────────────┐
  ├─(resolved)──→ [Action] Save Investigation Findings           │
  │                   ↓                                          │
  │               [Task] Resolution                               │
  │                   ↓ (communicate)                             │
  │               [Action] Communicate Resolution to Claimant     │
  │                   ↓                                           │
  │               [Action] Close Claim → End "Closed"              │
  └─(missing_info)                                                │
        ↓                                                         │
    [Action] Request Additional Information                      │
        ↓                                                         │
    [Task] Register Received Information                          │
        ├─(continue_investigation) ────────────────────────────────┘
        └─(close_no_response)
              ↓
          [Action] Close Claim - No Response → End "Closed - No Response"
```

---

#### Node: Task — `run_config` (Initiation Form)

Special pre-workflow launch form (see the "Initiation Form" section of the main reference document). Has no outgoing transitions; the engine fires Start automatically once it is submitted.

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.1//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.1.dtd">
<workflow-form>
  <text label="&lt;strong&gt;New complaint / claim intake&lt;/strong&gt;" name="headerIntake" />
  <input label="Claimant name" name="contactName" type="text" timeFormat="none">
    <validator type="req"/>
  </input>
  <input label="Contact email" name="contactEmail" type="text" timeFormat="none">
    <validator type="req"/>
    <validator type="email"/>
  </input>
  <input label="Contact phone" name="contactPhone" type="text" timeFormat="none" />
  <textarea label="Complaint description" name="claimDescription" type="text">
    <validator type="req"/>
  </textarea>
</workflow-form>
```

**Assignment:**
```groovy
"";
```

- The intake fields (`contactName`, `contactEmail`, `contactPhone`, `claimDescription`) are stored in the workflow context as typed form objects (`Input`/`TextArea`) as soon as the form is submitted — the next node reads them with `WorkflowUtils.getFormElementValue`, not by casting.
- `assignExpr` is always empty — here written simply as `"";` (a Groovy script implicitly returns the value of its last expression, so the explicit `return` keyword is not required, though `return "";` is equally valid) — **do not** use `getUsersByRole` or any assignment logic here, regardless of how other tasks in the same workflow are assigned. See the "Initiation Form" reference for the full rule.

---

#### Node: Start — `workflow.start`

Standard start node. This workflow starts **without an associated OpenKM node** — `uuid` and `node` are not present in the initial context. Unnamed transition → "Generate Claim Reference and Initial Data".

---

#### Node: Action — "Generate Claim Reference and Initial Data"

Creates the entire repository structure for the new case and initialises all context variables needed by subsequent nodes, reading directly from the `run_config` form values (there is no intermediate "Task" that re-collects or confirms this data).

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;
import java.util.Calendar;

OKMWebservices ws = WebservicesHelper.getInstance();

// --- Generate a sequential claim reference (CLM-YYYY-NNN) ---
// Requires the APP_COUNTER table (see script.sql) with a row counter_key='complaint'
SqlQueryResults counterResult = ws.repository.executeSqlQuery(
    "SELECT counter_value FROM APP_COUNTER WHERE counter_key = 'complaint'");
int currentValue = Integer.parseInt(counterResult.getResults().get(0).getColumns().get(0));
int nextValue = currentValue + 1;
ws.repository.executeSqlQuery(
    "UPDATE APP_COUNTER SET counter_value = " + nextValue + " WHERE counter_key = 'complaint'");

int year = Calendar.getInstance().get(Calendar.YEAR);
String caseNumber = String.format("CLM-%d-%03d", year, currentValue);
FileLogger.info("complaint-management", "Case number generated: " + caseNumber);

// --- Read the data entered by the user in the "run_config" intake form ---
String contactName = WorkflowUtils.getFormElementValue(context, "contactName");
String contactEmail = WorkflowUtils.getFormElementValue(context, "contactEmail");
String contactPhone = WorkflowUtils.getFormElementValue(context, "contactPhone");
String claimDescription = WorkflowUtils.getFormElementValue(context, "claimDescription");

// --- Create a dedicated folder + record to hold this complaint case ---
// TODO: adjust the base repository path to match your OpenKM folder structure.
String yearFolderPath = "/okm:root/Complaints/" + year;
if (!ws.repository.hasNode(yearFolderPath)) {
  ws.folder.createMissingFolders(yearFolderPath);
}
String yearFolderUuid = ws.repository.getNodeUuid(yearFolderPath);

String recordPath = yearFolderPath + "/" + caseNumber;
if (!ws.repository.hasNode(recordPath)) {
  ws.record.create(yearFolderUuid, caseNumber, "", 0);
}
String recordUuid = ws.repository.getNodeUuid(recordPath);

// --- Attach this workflow process instance to the new complaint record ---
WorkflowUtils.setProcessInstanceNode(context, recordUuid);
FileLogger.info("complaint-management", "Process instance attached to record " + recordUuid);

// --- Create the okg:complaint metadata group with the intake data + case number ---
Map<String,String> properties = new HashMap();
properties.put("okp:complaint.contact_name", contactName);
properties.put("okp:complaint.contact_email", contactEmail);
properties.put("okp:complaint.contact_phone", contactPhone);
properties.put("okp:complaint.claim_description", claimDescription);
properties.put("okp:complaint.case_number", caseNumber);
properties.put("okp:complaint.status", "[\"new\"]");
if (!ws.propertyGroup.hasGroup(recordUuid, "okg:complaint")) {
  ws.propertyGroup.addGroup(recordUuid, "okg:complaint", properties);
} else {
  ws.propertyGroup.setProperties(recordUuid, "okg:complaint", properties);
}

// --- Plain-string context variables (for Mail templates and other scripts) ---
context.put("caseNumber", caseNumber);
context.put("contactName", contactName);
context.put("contactEmail", contactEmail);
context.put("contactPhone", contactPhone);
context.put("claimDescription", claimDescription);

// --- Typed objects to pre-fill readonly form fields (data=) ---
Input caseNumberInput = new Input();
caseNumberInput.setValue(caseNumber);
context.put("caseNumberData", caseNumberInput);

Input contactNameInput = new Input();
contactNameInput.setValue(contactName);
context.put("contactNameData", contactNameInput);

Input contactEmailInput = new Input();
contactEmailInput.setValue(contactEmail);
context.put("contactEmailData", contactEmailInput);

TextArea claimDescriptionInput = new TextArea();
claimDescriptionInput.setValue(claimDescription);
context.put("claimDescriptionData", claimDescriptionInput);

// --- Prepare a folder to attach the resolution document later (task "Resolution") ---
String resolutionFolderPath = recordPath + "/resolution";
if (!ws.repository.hasNode(resolutionFolderPath)) {
  ws.folder.createMissingFolders(resolutionFolderPath);
}
String resolutionFolderUuid = ws.repository.getNodeUuid(resolutionFolderPath);
context.put("resolutionFolderUuid", resolutionFolderUuid);

Upload resolutionUpload = new Upload();
resolutionUpload.setFolderUuid(resolutionFolderUuid);
context.put("resolutionUploadParams", resolutionUpload);
```

- `ws.record.create(yearFolderUuid, caseNumber, "", 0)` mirrors the Purchase Order / Expense Report pattern exactly.
- `WorkflowUtils.setProcessInstanceNode(context, recordUuid)` is what lets `uuid`/`node` be used from this point onward as if the workflow had started on an existing node.
- The `okg:complaint` group is written with `addGroup` here (not `setProperties`) because the record was just created and has no metadata group yet — see the "Create or update a metadata group" pattern.
- `resolutionUploadParams` (an `Upload` object pointed at the `resolution` subfolder) is prepared here, three nodes before it is actually used in the Resolution task's form — this front-loads all repository/folder setup into a single Action node instead of spreading `ws.folder`/`ws.repository` calls across the workflow.

---

#### Node: Mail — "Acknowledgement of Receipt"

| Field | Value |
|-------|-------|
| Recipients | `${contactEmail}` |
| Subject | `We have received your complaint - Case ${caseNumber}` |
| Body | HTML, confirms receipt and states `${caseNumber}`. |

Plain-string context variables (not typed form objects) are used directly in `${...}` Mail node templating — see the Mail Management example (Example 08).

---

#### Node: Task — "Classification"

**Assignment:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

// TODO: adjust "ROLE_CLAIMS_TRIAGE" to the actual OpenKM role used by the team that classifies complaints.
OKMWebservices ws = WebservicesHelper.getInstance();
List<String> userList = new ArrayList();
for (CommonUser commonUser : ws.auth.getUsersByRole("ROLE_CLAIMS_TRIAGE")) {
  userList.add(commonUser.getId());
}
return userList;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <text label="&lt;strong&gt;Complaint classification&lt;/strong&gt;" name="headerClassification" />
  <input label="Case number" name="caseNumberDisplay" type="text" readonly="true" data="caseNumberData" />
  <input label="Claimant" name="contactNameDisplay" type="text" readonly="true" data="contactNameData" />
  <input label="Contact email" name="contactEmailDisplay" type="text" readonly="true" data="contactEmailData" />
  <textarea label="Complaint description" name="claimDescriptionDisplay" type="text" readonly="true" data="claimDescriptionData" />
  <select label="Complaint type" name="claimType" type="simple">
    <option label="Product" value="product"/>
    <option label="Service" value="service"/>
    <option label="Billing" value="billing"/>
    <option label="Customer service" value="customer_service"/>
    <option label="Delivery / lead time" value="delivery"/>
    <option label="Other" value="other"/>
    <validator type="req"/>
  </select>
  <select label="Priority" name="priority" type="simple">
    <option label="Low" value="low"/>
    <option label="Medium" value="medium" selected="true"/>
    <option label="High" value="high"/>
    <validator type="req"/>
  </select>
  <select label="Department" name="department" type="simple">
    <option label="Customer Care" value="customer_care"/>
    <option label="Quality" value="quality"/>
    <option label="Operations" value="operations"/>
    <option label="Billing" value="billing"/>
    <option label="Legal" value="legal"/>
    <option label="Other" value="other"/>
    <validator type="req"/>
  </select>
  <textarea label="Classification comments" name="classificationComments" type="text" />
  <button label="Classify and continue" name="classify" transition="classify" style="yes" color="success" validate="true" />
</workflow-form>
```

Single outgoing transition `classify` → "Assignment".

---

#### Node: Task — "Assignment"

**Assignment:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

// TODO: adjust "ROLE_CLAIMS_COORDINATOR" to the actual OpenKM role used by the dispatch/coordination team.
OKMWebservices ws = WebservicesHelper.getInstance();
List<String> userList = new ArrayList();
for (CommonUser commonUser : ws.auth.getUsersByRole("ROLE_CLAIMS_COORDINATOR")) {
  userList.add(commonUser.getId());
}
return userList;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <text label="&lt;strong&gt;Complaint assignment&lt;/strong&gt;" name="headerAssignment" />
  <input label="Case number" name="caseNumberDisplay2" type="text" readonly="true" data="caseNumberData" />
  <textarea label="Complaint description" name="claimDescriptionDisplay3" type="text" readonly="true" data="claimDescriptionData" />
  <select label="Complaint type" name="claimType" type="simple" readonly="true" data="claimType">
    <option label="Product" value="product"/>
    <option label="Service" value="service"/>
    <option label="Billing" value="billing"/>
    <option label="Customer service" value="customer_service"/>
    <option label="Delivery / lead time" value="delivery"/>
    <option label="Other" value="other"/>
  </select>
  <select label="Priority" name="priority" type="simple" readonly="true" data="priority">
    <option label="Low" value="low"/>
    <option label="Medium" value="medium"/>
    <option label="High" value="high"/>
  </select>
  <select label="Department" name="department" type="simple" readonly="true" data="department">
    <option label="Customer Care" value="customer_care"/>
    <option label="Quality" value="quality"/>
    <option label="Operations" value="operations"/>
    <option label="Billing" value="billing"/>
    <option label="Legal" value="legal"/>
    <option label="Other" value="other"/>
  </select>
  <textarea label="Classification comments" name="classificationCommentsDisplay" type="text" readonly="true" data="classificationComments" />
  <select label="Assignee" name="assignee" type="simple"
     className="com.openkm.plugin.form.values.OptionSelectUserList">
    <validator type="req"/>
  </select>
  <textarea label="Assignment comments" name="assignmentComments" type="text" />
  <button label="Assign" name="assign" transition="assign" style="yes" color="success" validate="true" />
</workflow-form>
```

- `claimType`/`priority`/`department` are redeclared as `readonly="true"` selects with `data="..."` pointing at the *same* field name they were submitted under in Classification — the standard read-only redisplay pattern.
- `Complaint description` and `Classification comments` are also redisplayed read-only here, each via a distinct `data` key (`claimDescriptionData`, `classificationComments`) so the coordinator has full context before picking an assignee.
- `Assignee` uses the `OptionSelectUserList` plugin — the coordinator can pick **any** OpenKM user, not just members of a fixed role. This is what makes the assignment "manual" rather than pool-based.

Single outgoing transition `assign` → "Save Classification and Assignment".

---

#### Node: Action — "Save Classification and Assignment"

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();
String uuid = context.get("uuid");

Select claimType = (Select) context.get("claimType");
Select priority = (Select) context.get("priority");
Select department = (Select) context.get("department");
Select assignee = (Select) context.get("assignee");

context.put("assigneeId", assignee.getValue());

Map<String,String> properties = new HashMap();
properties.put("okp:complaint.claim_type", "[\"" + claimType.getValue() + "\"]");
properties.put("okp:complaint.priority", "[\"" + priority.getValue() + "\"]");
properties.put("okp:complaint.department", "[\"" + department.getValue() + "\"]");
properties.put("okp:complaint.assigned_to", assignee.getValue());
properties.put("okp:complaint.status", "[\"assigned\"]");
ws.propertyGroup.setProperties(uuid, "okg:complaint", properties);

FileLogger.info("complaint-management", "Classification and assignment saved for " + context.get("caseNumber"));
```

- Stores `assigneeId` (a plain string) in context — every later task in the investigation/resolution branch reads this key as the fallback assignee.
- This is the first of three "sync metadata immediately, don't wait until the end" actions in this workflow (see also "Save Investigation Findings" and "Close Claim").

---

#### Node: Task — "Investigation"

**Assignment (pool → direct re-assignment via task history):**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.okmflow.rest.dto.*;
import com.openkm.util.*;
import java.util.*;

// If this task has already run before (re-entry after "Register Received Information"),
// re-assign it to the same investigator.
def procIns = context.get("processInstance");
long piId = procIns.getId();
List<TaskInstanceDTO> prevTasks = WorkflowUtils.getTaskInstances(piId, "Investigation");
if (prevTasks != null && !prevTasks.isEmpty()) {
  for (TaskInstanceDTO t : prevTasks) {
    if (t.getActor() != null && !t.getActor().isEmpty()) {
      return t.getActor();
    }
  }
}

// First time: assign to the person chosen in "Assignment"
String assigneeId = context.get("assigneeId");
return assigneeId;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <text label="&lt;strong&gt;Investigation&lt;/strong&gt;" name="headerInvestigation" />
  <input label="Case number" name="caseNumberDisplay3" type="text" readonly="true" data="caseNumberData" />
  <textarea label="Complaint description" name="claimDescriptionDisplay2" type="text" readonly="true" data="claimDescriptionData" />
  <textarea label="Additional information received (if any)" name="infoReceivedDisplay" type="text" readonly="true" data="infoReceivedData" />
  <textarea label="Investigation notes / findings" name="investigationFindings" type="text">
    <validator type="req"/>
  </textarea>
  <button label="Investigation complete - move to resolution" name="resolved" transition="resolved" style="yes" color="success" validate="true" />
  <button label="Missing information from claimant" name="missing_info" transition="missing_info" style="change" color="warning" validate="true" />
</workflow-form>
```

| Button | Transition | Destination |
|--------|------------|--------------|
| Investigation complete - move to resolution | `resolved` | Save Investigation Findings → Resolution |
| Missing information from claimant | `missing_info` | Request Additional Information |

`WorkflowUtils.getTaskInstances(piId, "Investigation")` is queried by name — this same call, with the same literal `"Investigation"` string, is reused unchanged in "Register Received Information" and "Resolution" below, so all three tasks always resolve to the same physical user without re-deriving it from `assigneeId` each time (except as a fallback on first entry).

---

#### Node: Action — "Save Investigation Findings"

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();
String uuid = context.get("uuid");

def findings = context.get("investigationFindings");
String findingsText = (findings == null) ? "" : findings.getValue();

Map<String,String> properties = new HashMap();
properties.put("okp:complaint.investigation_findings", findingsText);
properties.put("okp:complaint.status", "[\"pending_resolution\"]");
ws.propertyGroup.setProperties(uuid, "okg:complaint", properties);

FileLogger.info("complaint-management", "Investigation findings saved for " + context.get("caseNumber"));
```

Kept as a separate Action node between Investigation and Resolution (rather than folded into Investigation's own logic, which is not possible — Task nodes don't run scripts on submit) so the pattern stays consistent with "Save Classification and Assignment" and "Close Claim": every metadata write happens right after the task that produced the data, not accumulated until the very end.

---

#### Node: Action — "Request Additional Information"

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

String contactEmail = context.get("contactEmail");
String contactName = context.get("contactName");
String caseNumber = context.get("caseNumber");
def findings = context.get("investigationFindings");
String findingsText = (findings == null) ? "" : findings.getValue();

String from = "noreply@nomail.com"; // TODO: adjust to the real sending address
List<String> to = new ArrayList();
to.add(contactEmail);
String subject = "We need more information about your complaint - Case " + caseNumber;

String body = "Dear " + contactName + ",<br/><br/>";
body += "We are reviewing your complaint with case number " + caseNumber + " and need additional information to continue the investigation.<br/><br/>";
if (!findingsText.equals("")) {
  body += "Details of what we need:<br/>" + findingsText + "<br/><br/>";
}
body += "Please reply to this email with the requested information, referencing the case number.<br/><br/>";
body += "Kind regards,<br/>Customer Care Team";

ws.mail.sendMail(from, to, subject, body);
FileLogger.info("complaint-management", "Additional information request sent for " + caseNumber);
```

Uses `ws.mail.sendMail` directly (SDK), not a Mail node — because the body needs to conditionally include the investigator's findings text, which requires branching logic that only an Action node's script can express.

---

#### Node: Task — "Register Received Information"

**Assignment:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.okmflow.rest.dto.*;
import com.openkm.util.*;
import java.util.*;

def procIns = context.get("processInstance");
long piId = procIns.getId();
List<TaskInstanceDTO> prevTasks = WorkflowUtils.getTaskInstances(piId, "Investigation");
if (prevTasks != null && !prevTasks.isEmpty()) {
  for (TaskInstanceDTO t : prevTasks) {
    if (t.getActor() != null && !t.getActor().isEmpty()) {
      return t.getActor();
    }
  }
}
// Should not normally happen: fall back to the assigned responsible
String assigneeId = context.get("assigneeId");
return assigneeId;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <text label="&lt;strong&gt;Register received information&lt;/strong&gt;" name="headerRegister" />
  <input label="Case number" name="caseNumberDisplay4" type="text" readonly="true" data="caseNumberData" />
  <textarea label="Previous investigation notes" name="investigationFindingsDisplay" type="text" readonly="true" data="investigationFindings" />
  <textarea label="Information received from the claimant" name="infoReceived" type="text">
    <validator type="req"/>
  </textarea>
  <button label="Continue investigation" name="continue_investigation" transition="continue_investigation" style="yes" color="success" validate="true" />
  <button label="Close - no response" name="close_no_response" transition="close_no_response" confirmation="The claimant has not responded. Confirm you want to close the case without resolution?" style="no" color="danger" validate="true" />
</workflow-form>
```

| Button | Transition | Destination |
|--------|------------|--------------|
| Continue investigation | `continue_investigation` | back to Investigation (loop) |
| Close - no response | `close_no_response` | Close Claim - No Response → End "Closed - No Response" |

The `close_no_response` button uses `confirmation="..."` to force a confirmation dialog before ending the case without a resolution — the only button in this workflow that does so.

**Diagram layout note:** this task and "Request Additional Information" are placed to one side of the main path rather than stacked directly underneath "Investigation", with `left`/`right` `source`/`targetPosition` on the nodes carrying the loop transition instead of the default `top`/`bottom` — see the "Loop-back branches" positioning pattern in the "Node positioning conventions" section above, which uses this exact loop as its worked example.

---

#### Node: Task — "Resolution"

**Assignment:** same task-history pattern as Investigation (see above) — deliberately **not** a new role/approval step. The person who investigated resolves directly.
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.okmflow.rest.dto.*;
import com.openkm.util.*;
import java.util.*;

// The investigator resolves the complaint directly (no additional approval step):
// re-assign to the same actor who completed "Investigation".
def procIns = context.get("processInstance");
long piId = procIns.getId();
List<TaskInstanceDTO> prevTasks = WorkflowUtils.getTaskInstances(piId, "Investigation");
if (prevTasks != null && !prevTasks.isEmpty()) {
  for (TaskInstanceDTO t : prevTasks) {
    if (t.getActor() != null && !t.getActor().isEmpty()) {
      return t.getActor();
    }
  }
}
String assigneeId = context.get("assigneeId");
return assigneeId;
```

**Form:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE workflow-form PUBLIC "-//OpenKM//DTD Workflow Form 1.0//EN"
                                "https://www.openkm.com/dtd/workflow-form-1.0.dtd">
<workflow-form>
  <text label="&lt;strong&gt;Resolution&lt;/strong&gt;" name="headerResolution" />
  <input label="Case number" name="caseNumberDisplay5" type="text" readonly="true" data="caseNumberData" />
  <textarea label="Investigation notes" name="investigationFindingsDisplay2" type="text" readonly="true" data="investigationFindings" />
  <select label="Resolution type" name="resolutionType" type="simple">
    <option label="Accepted" value="accepted"/>
    <option label="Rejected" value="rejected"/>
    <option label="Partially accepted" value="partial"/>
    <validator type="req"/>
  </select>
  <textarea label="Resolution text (will be sent to the claimant)" name="resolutionText" type="text">
    <validator type="req"/>
  </textarea>
  <upload label="Resolution document (optional)" name="resolutionDocument" data="resolutionUploadParams" allowedExtensions="pdf;doc;docx" />
  <button label="Finish and notify the claimant" name="communicate" transition="communicate" style="yes" color="success" validate="true" />
</workflow-form>
```

Single outgoing transition `communicate` → "Communicate Resolution to Claimant". The `Resolution document` upload field uses `data="resolutionUploadParams"`, the `Upload` descriptor prepared three nodes earlier in "Generate Claim Reference and Initial Data" — see the "Pre-configure an upload field destination via context" pattern in Example 05.

---

#### Node: Action — "Communicate Resolution to Claimant"

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;

OKMWebservices ws = WebservicesHelper.getInstance();

String contactEmail = context.get("contactEmail");
String contactName = context.get("contactName");
String caseNumber = context.get("caseNumber");
String resolutionFolderUuid = context.get("resolutionFolderUuid");

Select resolutionTypeSelect = (Select) context.get("resolutionType");
String resolutionType = resolutionTypeSelect.getValue();

def resolutionTextField = context.get("resolutionText");
String resolutionText = resolutionTextField.getValue();

String resolutionLabel;
if (resolutionType.equals("accepted")) {
  resolutionLabel = "Accepted";
} else if (resolutionType.equals("rejected")) {
  resolutionLabel = "Rejected";
} else {
  resolutionLabel = "Partially accepted";
}

// Check whether a resolution document was attached in the previous task
List<String> docsId = new ArrayList();
List<Document> resolutionDocs = ws.document.getChildren(resolutionFolderUuid);
for (Document d : resolutionDocs) {
  docsId.add(d.getUuid());
}

List<String> to = new ArrayList();
to.add(contactEmail);
String from = "noreply@nomail.com"; // TODO: adjust to the real sending address
String subject = "Resolution of your complaint - Case " + caseNumber;

String body = "Dear " + contactName + ",<br/><br/>";
body += "We would like to inform you of the resolution of your complaint with case number " + caseNumber + ".<br/><br/>";
body += "<strong>Outcome: " + resolutionLabel + "</strong><br/><br/>";
body += resolutionText + "<br/><br/>";
if (!docsId.isEmpty()) {
  body += "Please find attached documentation related to this resolution.<br/><br/>";
}
body += "If you have any questions, please reply to this email referencing your case number.<br/><br/>";
body += "Kind regards,<br/>Customer Care Team";

if (docsId.isEmpty()) {
  ws.mail.sendMail(from, to, subject, body);
} else {
  ws.mail.sendMailWithAttachments(from, to, new ArrayList(), new ArrayList(), new ArrayList(),
      subject, body, docsId, resolutionFolderUuid);
}

context.put("resolutionLabel", resolutionLabel);
FileLogger.info("complaint-management", "Resolution communication sent for " + caseNumber);
```

- Retrieves any uploaded resolution document by listing the children of the dedicated `resolution` subfolder (`ws.document.getChildren`) — the "List documents in a folder" pattern from Example 05.
- Conditionally calls `ws.mail.sendMail` (no attachment) or `ws.mail.sendMailWithAttachments` (attachment) — since the native Mail node type has no attachment support, this step must be an Action node when a document may need to be sent.

---

#### Node: Action — "Close Claim"

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;
import java.text.SimpleDateFormat;
import java.util.Date;

OKMWebservices ws = WebservicesHelper.getInstance();
String uuid = context.get("uuid");

Select resolutionTypeSelect = (Select) context.get("resolutionType");

Map<String,String> closeProps = new HashMap();
closeProps.put("okp:complaint.resolution_type", "[\"" + resolutionTypeSelect.getValue() + "\"]");
closeProps.put("okp:complaint.status", "[\"closed\"]");
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
closeProps.put("okp:complaint.closed_date", sdf.format(new Date()));
ws.propertyGroup.setProperties(uuid, "okg:complaint", closeProps);

FileLogger.info("complaint-management", "Case closed: " + context.get("caseNumber"));
```

`resolutionType` is read again here from context (it is still available — nothing overwrote that key since the Resolution task) so that `okp:complaint.resolution_type` is persisted to metadata, in addition to being used to build the outgoing email in the previous node.

---

#### Node: Action — "Close Claim - No Response"

**Script:**
```groovy
import com.openkm.sdk4j.impl.OKMWebservices;
import com.openkm.sdk4j.bean.*;
import com.openkm.okmflow.util.*;
import com.openkm.okmflow.bean.*;
import com.openkm.bean.form.*;
import com.openkm.util.*;
import java.util.*;
import java.text.SimpleDateFormat;
import java.util.Date;

OKMWebservices ws = WebservicesHelper.getInstance();
String uuid = context.get("uuid");

Map<String,String> closeProps = new HashMap();
closeProps.put("okp:complaint.status", "[\"closed_no_response\"]");
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
closeProps.put("okp:complaint.closed_date", sdf.format(new Date()));
ws.propertyGroup.setProperties(uuid, "okg:complaint", closeProps);

FileLogger.info("complaint-management", "Case closed without response: " + context.get("caseNumber"));
```

Mirrors "Close Claim" but for the alternate terminal branch reached from "Register Received Information" — kept as a separate node (rather than merging the two End paths) so the two closure reasons remain distinguishable both by which End node was reached and by `status` (`closed` vs `closed_no_response`).

### Patterns illustrated in this example

| Pattern | Where applied |
|---------|----------------|
| `run_config` feeding record creation directly | No "Request Submission"-style Task after Start — `run_config` collects the data, and the very first Action node both reads it and creates the record, in one step. |
| `run_config`'s `assignExpr` is always empty | The reserved launch-form task must never carry role/user assignment logic, regardless of how other tasks in the same workflow are assigned — see the "Initiation Form" reference. |
| Manual (non-role-based) assignment via `OptionSelectUserList` | "Assignment" task — the coordinator picks any user as assignee, rather than the workflow computing it from a department/role mapping (contrast with Expense Report Approval's department→manager table). |
| No separate approval step | "Resolution" is assigned to the same person as "Investigation" via task history — there is no manager/approver task in this workflow at all, by explicit design choice. |
| Metadata synced at every stage, not just at the end | "Save Classification and Assignment", "Save Investigation Findings" and "Close Claim" each write their own slice of `okg:complaint` immediately, rather than accumulating everything into one final write. |
| Dedicated subfolder prepared ahead of time for a later optional upload | `resolutionUploadParams` is created in the very first Action node, three nodes before the Resolution task where it is actually used. |
| Loop-back branch with off-axis diagram layout | "Request Additional Information" / "Register Received Information" — see "Loop-back branches" in "Node positioning conventions" above. |
| Conditional attachment on a Mail send | "Communicate Resolution to Claimant" — `ws.mail.sendMail` vs `ws.mail.sendMailWithAttachments` depending on whether a resolution document was uploaded. |
