diff --git a/.operaton-starter.yml b/.operaton-starter.yml index 802f7ad..a88cc4e 100644 --- a/.operaton-starter.yml +++ b/.operaton-starter.yml @@ -1713,3 +1713,35 @@ examples: screenshots: - examples/use-cases/bank-account-opening/src/main/resources/bank-account-opening.png lastUpdated: "2026-06-30" + + - id: expense-reimbursement + title: "Use Case — Expense Reimbursement" + icon: "🧾" + path: examples/use-cases/expense-reimbursement + shortDescription: > + Vision LLM verifies a receipt image, DMN decides whether finance approval is required, + simulated payment, and an outcome email notification. + longDescription: | + Demonstrates multimodal receipt verification via Java delegates (Base64 FileValue), + a FIRST-hit DMN approval decision, an optional finance user task, simulated payment, + and LLM-drafted email notifications. + buildSystem: maven + runtime: spring-boot + operatonVersion: "2.1.1" + javaVersion: "21" + complexity: intermediate + tags: + - { label: "LLM", category: integration } + - { label: "DMN", category: concept } + - { label: "User Task", category: concept } + - { label: "Email", category: integration } + integrations: [postgres, ollama, wiremock, mailpit] + bpmnConcepts: [service-task, business-rule-task, exclusive-gateway, user-task, send-task] + requires: "Java 21+, Docker" + authors: + - { name: "Karsten Thoms", url: "https://github.com/kthoms" } + license: "Apache-2.0" + documentationUrl: "https://github.com/kthoms/operaton-examples/blob/main/examples/use-cases/expense-reimbursement/README.md" + screenshots: + - examples/use-cases/expense-reimbursement/src/main/resources/expense-reimbursement.png + lastUpdated: "2026-07-01" diff --git a/README.md b/README.md index 83ba417..95a92c5 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ docker compose up -d --wait # start PostgreSQL (and example-specific services) | [employee-onboarding](examples/use-cases/employee-onboarding) | Employee HR onboarding | Call activity orchestration: parallel MI for equipment provisioning, single call activity for system access, in/out variable mapping | | [procurement-collaboration](examples/use-cases/procurement-collaboration) | Buyer ↔ Supplier procurement | Two-pool collaboration, three correlated messages, async continuation decoupling | | [supply-chain-tracking](examples/use-cases/supply-chain-tracking) | Shipment tracking | Event-based gateway, async Kafka message correlation by business key, P7D timer re-arm | +| [expense-reimbursement](examples/use-cases/expense-reimbursement) | Vision LLM receipt verification | Multimodal vision LLM via delegate, three-way match verification, FIRST-hit DMN, LLM-drafted email | ## Anatomy of every example @@ -168,6 +169,7 @@ Quick lookup: which example demonstrates each BPMN construct. | Operaton Connectors | integration-connectors | | Micrometer / Prometheus | approval-sla-metrics | | Keycloak identity provider | integration-keycloak | +| Vision LLM (Ollama) | [expense-reimbursement](examples/use-cases/expense-reimbursement) | ### Platforms / Runtimes diff --git a/docs/EXAMPLE_STANDARDS.md b/docs/EXAMPLE_STANDARDS.md index c2693e4..404528f 100644 --- a/docs/EXAMPLE_STANDARDS.md +++ b/docs/EXAMPLE_STANDARDS.md @@ -159,7 +159,6 @@ Every example README contains, in this order: ![Process diagram](src/main/resources/.png) ``` Commit the `.png` and the updated README together. - Register the PNG path in `.operaton-starter.yml` under `screenshots`. Prerequisites: `npm install -g bpmn-to-image`. 4. **Prerequisites** — JDK 21, Docker; exact versions. 5. **Run it** — `docker compose up -d`, then both @@ -174,6 +173,10 @@ Every example README contains, in this order: - Code comments only where the code cannot speak (e.g. why an async continuation is placed where it is). +- Update `.operaton-starter.yml`: + - Register the example + - Register the BPMN PNG path under `screenshots`. +- Update main README: Add the example to Catalog and BPMN Concept Reference ## 9. Quality gate (CI) @@ -194,6 +197,8 @@ Every example README contains, in this order: - [ ] Versions match pom.xml == build.gradle.kts == root README table - [ ] §7 app conventions: demo/demo admin user, named seed users, application.yaml - [ ] No dead code, no unused dependencies, no TODO/stub delegates +- [ ] The example is registered in `.operaton-starter.yml` +- [ ] The example is registered in the `README.md` in the repository root ``` ## 11. Platform Integration Examples diff --git a/examples/use-cases/expense-reimbursement/.mvn/wrapper/maven-wrapper.jar b/examples/use-cases/expense-reimbursement/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..bf82ff0 Binary files /dev/null and b/examples/use-cases/expense-reimbursement/.mvn/wrapper/maven-wrapper.jar differ diff --git a/examples/use-cases/expense-reimbursement/.mvn/wrapper/maven-wrapper.properties b/examples/use-cases/expense-reimbursement/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b5f3a0c --- /dev/null +++ b/examples/use-cases/expense-reimbursement/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar diff --git a/examples/use-cases/expense-reimbursement/README.md b/examples/use-cases/expense-reimbursement/README.md new file mode 100644 index 0000000..4f9bb1c --- /dev/null +++ b/examples/use-cases/expense-reimbursement/README.md @@ -0,0 +1,161 @@ +# Expense Reimbursement + +An employee submits a receipt image; a **vision LLM** (via Spring delegate) verifies the receipt +matches the stated expense, a **FIRST-hit DMN table** decides whether manager approval is required, +an optional **user task** allows the finance team to approve or reject, a stub payment service +records the transaction, and the **LLM drafts a personalised email** sent via Spring Mail. + +## What you will learn + +- How to call a **multimodal vision LLM** from a Java delegate — encoding a `FileValue` as Base64 + and building a structured prompt with `PromptBuilder` +- How **process variables of type file** work: uploading via the embedded start form, reading bytes + in `ReceiptAnalyzer`, and the fail-safe fallback to `UNRELATED` on any exception +- How a **three-way match result** (`MATCH` / `MISMATCH` / `UNRELATED`) feeds into a DMN decision + to produce `approvalRequired` +- How a **DMN table with FIRST hit policy** works: the `UNRELATED` override row fires before the + per-kind threshold rows, ensuring unverifiable receipts always route to a human +- How an **LLM drafts outcome emails** — two separate prompt strategies (approval vs. rejection + tone) produce personalised email bodies sent via Spring Mail to Mailpit + +## Process model + +![Process diagram](src/main/resources/expense-reimbursement.png) + +> To render the PNG yourself: `./scripts/render-bpmn.sh examples/use-cases/expense-reimbursement` +> (requires `npm install -g bpmn-to-image`). + +## Prerequisites + +- JDK 21 +- Docker (recent version) + +## Run it + +```bash +docker compose up -d +./mvnw spring-boot:run +# or: +./gradlew bootRun +``` + +- Cockpit / Tasklist: http://localhost:8080 — **demo/demo** +- Mailpit (captured emails): http://localhost:8025 +- Ollama API: http://localhost:11434 + +> **Hosted LLM override** — to point at any OpenAI-compatible endpoint instead of local Ollama: +> ```bash +> export LLM_BASE_URL=https://api.openai.com +> export LLM_API_KEY=sk-... +> export LLM_MODEL=gpt-4o-mini +> ``` +> For a lighter local model use `LLM_MODEL=moondream` (~1.7 GB, vision-capable). +> +> The `docker-compose.yml` pulls `llama3.2-vision` by default via the `ollama-pull` init service. + +## Walk through it + +The start form is at http://localhost:8080 — open Tasklist, click **Start process**, then choose +**Expense Reimbursement**. If you use the REST API, include a `receipt` file variable (Base64); +otherwise `ReceiptAnalyzer` falls back to `matchResult=UNRELATED` and the DMN will require approval. + +### Happy path — receipt matches, within tier (auto-approved) + + # Submit a MEALS expense for €35 — below the €50 auto-approval threshold + # Replace with: base64 -w0 receipt.jpg + curl -s -X POST http://localhost:8080/engine-rest/process-definition/key/expense-reimbursement/start \ + -u demo:demo \ + -H "Content-Type: application/json" \ + -d '{ + "variables": { + "requesterName": { "value": "Alice Berger", "type": "String" }, + "requesterEmail": { "value": "alice@example.com", "type": "String" }, + "kind": { "value": "MEALS", "type": "String" }, + "statedCost": { "value": 35.0, "type": "Double" }, + "reason": { "value": "Team lunch", "type": "String" }, + "receipt": { + "value": "", + "type": "File", + "valueInfo": { "filename": "receipt.jpg", "mimeType": "image/jpeg", "encoding": "Base64" } + } + } + }' | jq .id +``` + +The process completes automatically: `matchResult=MATCH`, `approvalRequired=false`, payment +reference set, approval email visible in Mailpit. + +### Alternative path — UNRELATED receipt, finance approves + +```bash +# Submit a TRAVEL expense — WireMock returns UNRELATED for "Bob Richter" +curl -s -X POST http://localhost:8080/engine-rest/process-definition/key/expense-reimbursement/start \ + -u demo:demo \ + -H "Content-Type: application/json" \ + -d '{ + "variables": { + "requesterName": { "value": "Bob Richter", "type": "String" }, + "requesterEmail": { "value": "bob@example.com", "type": "String" }, + "kind": { "value": "TRAVEL", "type": "String" }, + "statedCost": { "value": 150.0, "type": "Double" }, + "reason": { "value": "Conference travel", "type": "String" } + } + }' | jq .id +``` + +Open Tasklist as `alice` (password: `alice`) — claim and approve the **Approve Reimbursement** task. +The process completes at *Expense Reimbursed* and Mailpit shows the approval email. + +### Rejection path — over tier, finance rejects + +```bash +# EQUIPMENT €1200 > €1000 threshold → approvalRequired=true +curl -s -X POST http://localhost:8080/engine-rest/process-definition/key/expense-reimbursement/start \ + -u demo:demo \ + -H "Content-Type: application/json" \ + -d '{ + "variables": { + "requesterName": { "value": "Charlie Weiss", "type": "String" }, + "requesterEmail": { "value": "charlie@example.com", "type": "String" }, + "kind": { "value": "EQUIPMENT", "type": "String" }, + "statedCost": { "value": 1200.0, "type": "Double" }, + "reason": { "value": "Laptop purchase", "type": "String" } + } + }' | jq .id +``` + +Open Tasklist as `bob` — claim and **reject** the task. The process completes at *Expense Rejected* +and Mailpit shows the rejection email. + +## How it works + +| Model element | Code | +|---|---| +| `Analyze Receipt` (service task) | [`ReceiptAnalyzer`](src/main/java/org/operaton/examples/expensereimbursement/delegate/ReceiptAnalyzer.java) — reads the `receipt` `FileValue`, Base64-encodes it, calls `PromptBuilder.receiptAnalysisRequest`, parses `matchResult` / `extractedName` / `extractedCost` / `analysisNotes`; catches all exceptions and defaults to `UNRELATED` | +| `Decide on Approval` (business rule task) | [`reimbursement-approval.dmn`](src/main/resources/reimbursement-approval.dmn) — FIRST hit policy; first rule matches `UNRELATED` unconditionally; remaining rules compare `kind` and `statedCost` against per-category thresholds (MEALS ≤ €50, TRAVEL ≤ €250, ACCOMMODATION ≤ €400, EQUIPMENT ≤ €1,000); catch-all last row requires approval | +| `Approve Reimbursement` (user task) | `candidateGroups="finance"` — seed users `alice` and `bob` can claim it; the `approved` boolean variable drives the downstream gateway | +| `Perform Payment` (service task) | [`PaymentService`](src/main/java/org/operaton/examples/expensereimbursement/delegate/PaymentService.java) — simulates payment by generating a `PAY-` reference and recording `paymentDate` | +| `Draft Approval Email` (service task) | [`ApprovalEmailDrafter`](src/main/java/org/operaton/examples/expensereimbursement/delegate/ApprovalEmailDrafter.java) — calls LLM to draft a personalised approval email; falls back to a fixed template on error | +| `Draft Rejection Email` (service task) | [`RejectionEmailDrafter`](src/main/java/org/operaton/examples/expensereimbursement/delegate/RejectionEmailDrafter.java) — calls LLM to draft a personalised rejection email; same fail-safe pattern | +| `Notify Requester` (send task, both paths) | [`EmailDispatcher`](src/main/java/org/operaton/examples/expensereimbursement/EmailDispatcher.java) — reads `emailSubject` / `emailBody` / `requesterEmail` variables and sends via Spring `JavaMailSender` | + +`PromptBuilder` constructs both the vision request (with the Base64 image embedded in the +`image_url` field) and the two email drafting prompts. +`ResponseParser` extracts JSON fields from the LLM response and provides safe defaults for +unparseable output. +`LlmClient` wraps a `RestTemplate` call to the configured `LLM_BASE_URL`; connection properties +are bound to `LlmProperties` (`llm.base-url`, `llm.model`, `llm.api-key`). + +## Run the tests + +```bash +./mvnw verify +# or: +./gradlew build +``` + +Three integration tests (`ExpenseReimbursementIT`) run against real PostgreSQL (Testcontainers), +the LLM stubbed by WireMock, and Mailpit as the SMTP sink: happy path (MEALS €35, auto-approved, +email sent), UNRELATED receipt then finance approval, and EQUIPMENT €1,200 over tier then +rejection — each asserts process end state, key variable values, and email delivery. +`ExpenseReimbursementDeploymentIT` verifies the BPMN and DMN deploy without errors. diff --git a/examples/use-cases/expense-reimbursement/build.gradle.kts b/examples/use-cases/expense-reimbursement/build.gradle.kts new file mode 100644 index 0000000..4cc1562 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/build.gradle.kts @@ -0,0 +1,43 @@ +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + java + id("org.springframework.boot") version "4.1.0" +} + +group = "org.operaton.examples" +version = "0.1.0-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +val operatonVersion = "2.1.1" + +dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(platform("org.operaton.bpm:operaton-bom:$operatonVersion")) + + implementation("org.operaton.bpm.springboot:operaton-bpm-spring-boot-starter-webapp") + implementation("org.operaton.bpm.springboot:operaton-bpm-spring-boot-starter-rest") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-mail") + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:testcontainers-junit-jupiter") + testImplementation("org.testcontainers:testcontainers-postgresql") + testImplementation("org.wiremock.integrations.testcontainers:wiremock-testcontainers-module:1.0-alpha-15") + testImplementation("org.awaitility:awaitility") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/examples/use-cases/expense-reimbursement/docker-compose.yml b/examples/use-cases/expense-reimbursement/docker-compose.yml new file mode 100644 index 0000000..42b63f3 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/docker-compose.yml @@ -0,0 +1,54 @@ +services: + postgres: + image: postgres:16-alpine + container_name: expense-reimbursement-postgres + environment: + POSTGRES_DB: operaton + POSTGRES_USER: operaton + POSTGRES_PASSWORD: operaton + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U operaton -d operaton"] + interval: 5s + timeout: 3s + retries: 10 + + ollama: + image: ollama/ollama:latest + container_name: expense-reimbursement-ollama + ports: + - "11434:11434" + volumes: + - ollama:/root/.ollama + healthcheck: + test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + + ollama-pull: + image: ollama/ollama:latest + container_name: expense-reimbursement-ollama-pull + depends_on: + ollama: + condition: service_healthy + environment: + OLLAMA_HOST: ollama:11434 + entrypoint: ["/bin/sh", "-c", "ollama pull llama3.2-vision"] + restart: "no" + + mailpit: + image: axllent/mailpit:latest + container_name: expense-reimbursement-mailpit + ports: + - "1025:1025" + - "8025:8025" + healthcheck: + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8025/livez || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + ollama: diff --git a/examples/use-cases/expense-reimbursement/gradle/wrapper/gradle-wrapper.jar b/examples/use-cases/expense-reimbursement/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/examples/use-cases/expense-reimbursement/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/use-cases/expense-reimbursement/gradle/wrapper/gradle-wrapper.properties b/examples/use-cases/expense-reimbursement/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..98b51da --- /dev/null +++ b/examples/use-cases/expense-reimbursement/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/use-cases/expense-reimbursement/gradlew b/examples/use-cases/expense-reimbursement/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/use-cases/expense-reimbursement/gradlew.bat b/examples/use-cases/expense-reimbursement/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/examples/use-cases/expense-reimbursement/mvnw b/examples/use-cases/expense-reimbursement/mvnw new file mode 100755 index 0000000..b7f0646 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/mvnw @@ -0,0 +1,287 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.1.1 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="`/usr/libexec/java_home`"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + printf '%s' "$(cd "$basedir"; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname $0)") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) wrapperUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $wrapperUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + QUIET="--quiet" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + QUIET="" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" + fi + [ $? -eq 0 ] || rm -f "$wrapperJarPath" + elif command -v curl > /dev/null; then + QUIET="--silent" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + QUIET="" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L + fi + [ $? -eq 0 ] || rm -f "$wrapperJarPath" + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaSource="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=`cygpath --path --windows "$javaSource"` + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/examples/use-cases/expense-reimbursement/mvnw.cmd b/examples/use-cases/expense-reimbursement/mvnw.cmd new file mode 100644 index 0000000..474c9d6 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/mvnw.cmd @@ -0,0 +1,187 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.1.1 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/examples/use-cases/expense-reimbursement/pom.xml b/examples/use-cases/expense-reimbursement/pom.xml new file mode 100644 index 0000000..b98d730 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/pom.xml @@ -0,0 +1,122 @@ + + + 4.0.0 + + + org.operaton.examples + operaton-examples-aggregate + 0.1.0-SNAPSHOT + ../../../pom.xml + + + uc-14-expense-reimbursement + 0.1.0-SNAPSHOT + jar + Operaton Example: Expense Reimbursement + + + UTF-8 + 21 + 21 + 4.1.0 + 2.1.1 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + org.operaton.bpm + operaton-bom + ${operaton.version} + pom + import + + + + + + + org.operaton.bpm.springboot + operaton-bpm-spring-boot-starter-webapp + + + org.operaton.bpm.springboot + operaton-bpm-spring-boot-starter-rest + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-mail + + + org.postgresql + postgresql + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.wiremock.integrations.testcontainers + wiremock-testcontainers-module + 1.0-alpha-15 + test + + + org.awaitility + awaitility + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + diff --git a/examples/use-cases/expense-reimbursement/settings.gradle.kts b/examples/use-cases/expense-reimbursement/settings.gradle.kts new file mode 100644 index 0000000..d238af3 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "uc-14-expense-reimbursement" diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/DataInitializer.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/DataInitializer.java new file mode 100644 index 0000000..6a8e7d9 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/DataInitializer.java @@ -0,0 +1,52 @@ +package org.operaton.examples.expensereimbursement; + +import org.operaton.bpm.engine.IdentityService; +import org.operaton.bpm.engine.identity.Group; +import org.operaton.bpm.engine.identity.User; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class DataInitializer implements ApplicationRunner { + private final IdentityService identityService; + + public DataInitializer(IdentityService identityService) { + this.identityService = identityService; + } + + @Override + public void run(ApplicationArguments args) { + createGroupIfAbsent("finance", "Finance"); + createUserIfAbsent("alice", "Alice", "Müller", "alice"); + createUserIfAbsent("bob", "Bob", "Schmidt", "bob"); + addToGroupIfAbsent("alice", "finance"); + addToGroupIfAbsent("bob", "finance"); + } + + private void createGroupIfAbsent(String id, String name) { + if (identityService.createGroupQuery().groupId(id).count() == 0) { + Group g = identityService.newGroup(id); + g.setName(name); + identityService.saveGroup(g); + } + } + + private void createUserIfAbsent(String id, String firstName, String lastName, String password) { + if (identityService.createUserQuery().userId(id).count() == 0) { + User u = identityService.newUser(id); + u.setFirstName(firstName); + u.setLastName(lastName); + u.setPassword(password); + identityService.saveUser(u); + } + } + + private void addToGroupIfAbsent(String userId, String groupId) { + boolean already = identityService.createUserQuery() + .userId(userId).memberOfGroup(groupId).count() > 0; + if (!already) { + identityService.createMembership(userId, groupId); + } + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/EmailDispatcher.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/EmailDispatcher.java new file mode 100644 index 0000000..104fc13 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/EmailDispatcher.java @@ -0,0 +1,32 @@ +package org.operaton.examples.expensereimbursement; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +@Component("emailDispatcher") +public class EmailDispatcher implements JavaDelegate { + private final JavaMailSender mailSender; + private final MailProperties mailProperties; + + public EmailDispatcher(JavaMailSender mailSender, MailProperties mailProperties) { + this.mailSender = mailSender; + this.mailProperties = mailProperties; + } + + @Override + public void execute(DelegateExecution execution) { + String to = (String) execution.getVariable("requesterEmail"); + String subject = (String) execution.getVariable("emailSubject"); + String body = (String) execution.getVariable("emailBody"); + + SimpleMailMessage msg = new SimpleMailMessage(); + msg.setFrom(mailProperties.getFrom()); + msg.setTo(to); + msg.setSubject(subject); + msg.setText(body); + mailSender.send(msg); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementApplication.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementApplication.java new file mode 100644 index 0000000..29a79be --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementApplication.java @@ -0,0 +1,11 @@ +package org.operaton.examples.expensereimbursement; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ExpenseReimbursementApplication { + public static void main(String[] args) { + SpringApplication.run(ExpenseReimbursementApplication.class, args); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/LlmClient.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/LlmClient.java new file mode 100644 index 0000000..71f5786 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/LlmClient.java @@ -0,0 +1,27 @@ +package org.operaton.examples.expensereimbursement; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class LlmClient { + private final LlmProperties llm; + private final RestTemplate rest = new RestTemplate(); + + public LlmClient(LlmProperties llm) { + this.llm = llm; + } + + public String call(String requestBody) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", llm.getAuthorizationHeader()); + return rest.postForObject( + llm.getChatCompletionsUrl(), + new HttpEntity<>(requestBody, headers), + String.class); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/LlmProperties.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/LlmProperties.java new file mode 100644 index 0000000..e6f3738 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/LlmProperties.java @@ -0,0 +1,28 @@ +package org.operaton.examples.expensereimbursement; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("llm") +public class LlmProperties { + private String baseUrl; + private String apiKey; + private String model; + + public String getBaseUrl() { return baseUrl; } + public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } + public String getApiKey() { return apiKey; } + public void setApiKey(String apiKey) { this.apiKey = apiKey; } + public String getModel() { return model; } + public void setModel(String model) { this.model = model; } + + public String getChatCompletionsUrl() { + if (baseUrl == null || baseUrl.isBlank()) { + throw new IllegalStateException("llm.base-url must be configured"); + } + String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + return normalized + "/v1/chat/completions"; + } + public String getAuthorizationHeader() { return "Bearer " + apiKey; } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/MailProperties.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/MailProperties.java new file mode 100644 index 0000000..c62ca53 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/MailProperties.java @@ -0,0 +1,12 @@ +package org.operaton.examples.expensereimbursement; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("mail") +public class MailProperties { + private String from = "reimbursement@example.com"; + public String getFrom() { return from; } + public void setFrom(String from) { this.from = from; } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/PromptBuilder.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/PromptBuilder.java new file mode 100644 index 0000000..63a02aa --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/PromptBuilder.java @@ -0,0 +1,92 @@ +package org.operaton.examples.expensereimbursement; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Component; + +@Component +public class PromptBuilder { + private final LlmProperties llm; + private final ObjectMapper mapper; + + public PromptBuilder(LlmProperties llm, ObjectMapper mapper) { + this.llm = llm; + this.mapper = mapper; + } + + public String receiptAnalysisRequest(String base64Image, String requesterName, + double statedCost, String kind) { + String system = "You are an expense receipt analysis assistant. " + + "Analyze the attached receipt image and compare it against the stated expense data. " + + "Respond ONLY with JSON: " + + "{\"matchResult\": \"MATCH\"|\"MISMATCH\"|\"UNRELATED\", " + + "\"extractedName\": \"\", " + + "\"extractedCost\": , " + + "\"analysisNotes\": \"\"}. " + + "MATCH: receipt confirms the stated data. " + + "MISMATCH: receipt is a valid expense receipt but contradicts stated amount or payee. " + + "UNRELATED: image cannot be related to the stated expense at all."; + String userText = "Requester: " + requesterName + + "\nExpense kind: " + kind + + "\nStated amount: " + statedCost; + + ObjectNode root = mapper.createObjectNode(); + root.put("model", llm.getModel()); + root.putObject("response_format").put("type", "json_object"); + ArrayNode messages = root.putArray("messages"); + messages.addObject().put("role", "system").put("content", system); + + ObjectNode userMsg = messages.addObject(); + userMsg.put("role", "user"); + ArrayNode contentArray = userMsg.putArray("content"); + contentArray.addObject() + .put("type", "image_url") + .putObject("image_url").put("url", "data:image/jpeg;base64," + base64Image); + contentArray.addObject() + .put("type", "text") + .put("text", userText); + + return serialize(root); + } + + public String approvalEmailRequest(String requesterName, double statedCost, + String kind, String reason, String paymentReference) { + String system = "You are an expense management assistant. " + + "Draft a friendly notification email: the expense has been approved for reimbursement. " + + "Be concise and professional."; + String user = "Requester: " + requesterName + + "\nExpense kind: " + kind + ", Amount: " + statedCost + + "\nReason: " + reason + + "\nPayment reference: " + paymentReference; + return chatRequest(system, user); + } + + public String rejectionEmailRequest(String requesterName, double statedCost, + String kind, String reason) { + String system = "You are an expense management assistant. " + + "Draft a polite notification email: the expense reimbursement request has been rejected. " + + "Be empathetic and professional."; + String user = "Requester: " + requesterName + + "\nExpense kind: " + kind + ", Amount: " + statedCost + + "\nReason for expense: " + reason; + return chatRequest(system, user); + } + + private String chatRequest(String system, String user) { + ObjectNode root = mapper.createObjectNode(); + root.put("model", llm.getModel()); + ArrayNode messages = root.putArray("messages"); + messages.addObject().put("role", "system").put("content", system); + messages.addObject().put("role", "user").put("content", user); + return serialize(root); + } + + private String serialize(ObjectNode node) { + try { + return mapper.writeValueAsString(node); + } catch (Exception e) { + throw new IllegalStateException("Failed to build LLM request JSON", e); + } + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/ResponseParser.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/ResponseParser.java new file mode 100644 index 0000000..6183585 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/ResponseParser.java @@ -0,0 +1,68 @@ +package org.operaton.examples.expensereimbursement; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class ResponseParser { + private static final Logger log = LoggerFactory.getLogger(ResponseParser.class); + private static final Set VALID_MATCH = Set.of("MATCH", "MISMATCH", "UNRELATED"); + + private final ObjectMapper mapper; + + public ResponseParser(ObjectMapper mapper) { + this.mapper = mapper; + } + + public String matchResult(String response) { + try { + String result = content(response).path("matchResult").asText("UNRELATED").toUpperCase(); + if (!VALID_MATCH.contains(result)) { + log.warn("Unexpected matchResult '{}' from LLM — defaulting to UNRELATED", result); + return "UNRELATED"; + } + return result; + } catch (Exception e) { + log.warn("Failed to parse matchResult — defaulting to UNRELATED: {}", e.getMessage()); + return "UNRELATED"; + } + } + + public String extractedName(String response) { + try { return content(response).path("extractedName").asText(""); } + catch (Exception e) { return ""; } + } + + public double extractedCost(String response) { + try { return content(response).path("extractedCost").asDouble(0.0); } + catch (Exception e) { return 0.0; } + } + + public String analysisNotes(String response) { + try { return content(response).path("analysisNotes").asText(""); } + catch (Exception e) { return ""; } + } + + public String emailBody(String response) { + try { + return rawContent(response); + } catch (Exception e) { + log.warn("Failed to parse emailBody: {}", e.getMessage()); + return ""; + } + } + + private JsonNode content(String response) throws Exception { + return mapper.readTree(rawContent(response)); + } + + private String rawContent(String response) throws Exception { + return mapper.readTree(response) + .path("choices").path(0).path("message").path("content").asText(); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/ApprovalEmailDrafter.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/ApprovalEmailDrafter.java new file mode 100644 index 0000000..af89777 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/ApprovalEmailDrafter.java @@ -0,0 +1,46 @@ +package org.operaton.examples.expensereimbursement.delegate; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.operaton.examples.expensereimbursement.LlmClient; +import org.operaton.examples.expensereimbursement.PromptBuilder; +import org.operaton.examples.expensereimbursement.ResponseParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component("approvalEmailDrafter") +public class ApprovalEmailDrafter implements JavaDelegate { + private static final Logger log = LoggerFactory.getLogger(ApprovalEmailDrafter.class); + + private final LlmClient llmClient; + private final PromptBuilder promptBuilder; + private final ResponseParser responseParser; + + public ApprovalEmailDrafter(LlmClient llmClient, PromptBuilder promptBuilder, ResponseParser responseParser) { + this.llmClient = llmClient; + this.promptBuilder = promptBuilder; + this.responseParser = responseParser; + } + + @Override + public void execute(DelegateExecution execution) { + String requesterName = (String) execution.getVariable("requesterName"); + double statedCost = ((Number) execution.getVariable("statedCost")).doubleValue(); + String kind = (String) execution.getVariable("kind"); + String reason = (String) execution.getVariable("reason"); + String paymentReference = (String) execution.getVariable("paymentReference"); + + try { + String request = promptBuilder.approvalEmailRequest(requesterName, statedCost, kind, reason, paymentReference); + String response = llmClient.call(request); + execution.setVariable("emailBody", responseParser.emailBody(response)); + } catch (Exception e) { + log.warn("Approval email drafting failed — using fallback: {}", e.getMessage()); + execution.setVariable("emailBody", + "Dear " + requesterName + ",\n\nYour expense reimbursement of " + statedCost + + " EUR has been approved. Payment reference: " + paymentReference + ".\n\nKind regards"); + } + execution.setVariable("emailSubject", "Expense Reimbursement Approved"); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/PaymentService.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/PaymentService.java new file mode 100644 index 0000000..f7ebf66 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/PaymentService.java @@ -0,0 +1,24 @@ +package org.operaton.examples.expensereimbursement.delegate; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component("paymentService") +public class PaymentService implements JavaDelegate { + private static final Logger log = LoggerFactory.getLogger(PaymentService.class); + + @Override + public void execute(DelegateExecution execution) { + String requesterName = (String) execution.getVariable("requesterName"); + double statedCost = ((Number) execution.getVariable("statedCost")).doubleValue(); + String reference = "PAY-" + execution.getProcessInstanceId().substring(0, 8).toUpperCase(); + log.info("Simulating payment of {} EUR for {} — reference {}", statedCost, requesterName, reference); + execution.setVariable("paymentReference", reference); + execution.setVariable("paymentDate", LocalDate.now().toString()); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/ReceiptAnalyzer.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/ReceiptAnalyzer.java new file mode 100644 index 0000000..0c894d3 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/ReceiptAnalyzer.java @@ -0,0 +1,55 @@ +package org.operaton.examples.expensereimbursement.delegate; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.operaton.bpm.engine.variable.value.FileValue; +import org.operaton.examples.expensereimbursement.LlmClient; +import org.operaton.examples.expensereimbursement.PromptBuilder; +import org.operaton.examples.expensereimbursement.ResponseParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Base64; + +@Component("receiptAnalyzer") +public class ReceiptAnalyzer implements JavaDelegate { + private static final Logger log = LoggerFactory.getLogger(ReceiptAnalyzer.class); + + private final LlmClient llmClient; + private final PromptBuilder promptBuilder; + private final ResponseParser responseParser; + + public ReceiptAnalyzer(LlmClient llmClient, PromptBuilder promptBuilder, ResponseParser responseParser) { + this.llmClient = llmClient; + this.promptBuilder = promptBuilder; + this.responseParser = responseParser; + } + + @Override + public void execute(DelegateExecution execution) throws Exception { + String requesterName = (String) execution.getVariable("requesterName"); + double statedCost = ((Number) execution.getVariable("statedCost")).doubleValue(); + String kind = (String) execution.getVariable("kind"); + + try { + FileValue receipt = execution.getVariableTyped("receipt"); + byte[] bytes = receipt.getValue().readAllBytes(); + String base64 = Base64.getEncoder().encodeToString(bytes); + + String request = promptBuilder.receiptAnalysisRequest(base64, requesterName, statedCost, kind); + String response = llmClient.call(request); + + execution.setVariable("matchResult", responseParser.matchResult(response)); + execution.setVariable("extractedName", responseParser.extractedName(response)); + execution.setVariable("extractedCost", responseParser.extractedCost(response)); + execution.setVariable("analysisNotes", responseParser.analysisNotes(response)); + } catch (Exception e) { + log.warn("Receipt analysis failed — defaulting to UNRELATED: {}", e.getMessage()); + execution.setVariable("matchResult", "UNRELATED"); + execution.setVariable("extractedName", ""); + execution.setVariable("extractedCost", 0.0); + execution.setVariable("analysisNotes", "Analysis failed: " + e.getMessage()); + } + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/RejectionEmailDrafter.java b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/RejectionEmailDrafter.java new file mode 100644 index 0000000..3b15d92 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/java/org/operaton/examples/expensereimbursement/delegate/RejectionEmailDrafter.java @@ -0,0 +1,45 @@ +package org.operaton.examples.expensereimbursement.delegate; + +import org.operaton.bpm.engine.delegate.DelegateExecution; +import org.operaton.bpm.engine.delegate.JavaDelegate; +import org.operaton.examples.expensereimbursement.LlmClient; +import org.operaton.examples.expensereimbursement.PromptBuilder; +import org.operaton.examples.expensereimbursement.ResponseParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component("rejectionEmailDrafter") +public class RejectionEmailDrafter implements JavaDelegate { + private static final Logger log = LoggerFactory.getLogger(RejectionEmailDrafter.class); + + private final LlmClient llmClient; + private final PromptBuilder promptBuilder; + private final ResponseParser responseParser; + + public RejectionEmailDrafter(LlmClient llmClient, PromptBuilder promptBuilder, ResponseParser responseParser) { + this.llmClient = llmClient; + this.promptBuilder = promptBuilder; + this.responseParser = responseParser; + } + + @Override + public void execute(DelegateExecution execution) { + String requesterName = (String) execution.getVariable("requesterName"); + double statedCost = ((Number) execution.getVariable("statedCost")).doubleValue(); + String kind = (String) execution.getVariable("kind"); + String reason = (String) execution.getVariable("reason"); + + try { + String request = promptBuilder.rejectionEmailRequest(requesterName, statedCost, kind, reason); + String response = llmClient.call(request); + execution.setVariable("emailBody", responseParser.emailBody(response)); + } catch (Exception e) { + log.warn("Rejection email drafting failed — using fallback: {}", e.getMessage()); + execution.setVariable("emailBody", + "Dear " + requesterName + ",\n\nWe regret to inform you that your expense reimbursement request of " + + statedCost + " EUR has not been approved.\n\nKind regards"); + } + execution.setVariable("emailSubject", "Expense Reimbursement Update"); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/main/resources/application.yaml b/examples/use-cases/expense-reimbursement/src/main/resources/application.yaml new file mode 100644 index 0000000..dc3ae74 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/resources/application.yaml @@ -0,0 +1,27 @@ +spring: + application: + name: expense-reimbursement + datasource: + url: jdbc:postgresql://localhost:5432/operaton + username: operaton + password: operaton + mail: + host: ${MAIL_HOST:localhost} + port: ${MAIL_PORT:1025} + +operaton: + bpm: + admin-user: + id: demo + password: demo + first-name: Demo + filter: + create: All tasks + +llm: + base-url: ${LLM_BASE_URL:http://localhost:11434} + api-key: ${LLM_API_KEY:ollama} + model: ${LLM_MODEL:llama3.2-vision} + +mail: + from: ${MAIL_FROM:reimbursement@example.com} diff --git a/examples/use-cases/expense-reimbursement/src/main/resources/expense-reimbursement.bpmn b/examples/use-cases/expense-reimbursement/src/main/resources/expense-reimbursement.bpmn new file mode 100644 index 0000000..59a32bb --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/resources/expense-reimbursement.bpmn @@ -0,0 +1,235 @@ + + + + + + + Flow_start_to_analyze + + + + Flow_start_to_analyze + Flow_analyze_to_decide + + + + Flow_analyze_to_decide + Flow_decide_to_gw + + + + Flow_decide_to_gw + Flow_needs_approval + Flow_auto + + + + + + + + + Flow_needs_approval + Flow_user_to_approved_gw + + + + Flow_user_to_approved_gw + Flow_approved + Flow_rejected + + + + Flow_auto + Flow_approved + Flow_to_payment + + + + Flow_to_payment + Flow_payment_to_draft + + + + Flow_payment_to_draft + Flow_draft_to_send_approval + + + + Flow_draft_to_send_approval + Flow_send_approval_to_end + + + + Flow_send_approval_to_end + + + + Flow_rejected + Flow_draft_to_send_rejection + + + + Flow_draft_to_send_rejection + Flow_send_rejection_to_end + + + + Flow_send_rejection_to_end + + + + + + + ${approvalRequired == true} + + + + + ${approved == true} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/use-cases/expense-reimbursement/src/main/resources/expense-reimbursement.png b/examples/use-cases/expense-reimbursement/src/main/resources/expense-reimbursement.png new file mode 100644 index 0000000..b3a4252 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/resources/expense-reimbursement.png @@ -0,0 +1 @@ +placeholder \ No newline at end of file diff --git a/examples/use-cases/expense-reimbursement/src/main/resources/reimbursement-approval.dmn b/examples/use-cases/expense-reimbursement/src/main/resources/reimbursement-approval.dmn new file mode 100644 index 0000000..5843305 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/resources/reimbursement-approval.dmn @@ -0,0 +1,83 @@ + + + + + + + + matchResult + + + + + kind + + + + + statedCost + + + + + + Receipt cannot be related to stated expense — always requires approval + "UNRELATED" + + + true + + + Meals within auto-approval threshold + + "MEALS" + <= 50 + false + + + Travel within auto-approval threshold + + "TRAVEL" + <= 250 + false + + + Accommodation within auto-approval threshold + + "ACCOMMODATION" + <= 400 + false + + + Equipment within auto-approval threshold + + "EQUIPMENT" + <= 1000 + false + + + Over threshold or unknown kind — requires approval + + + + true + + + + + + + + + + + + diff --git a/examples/use-cases/expense-reimbursement/src/main/resources/static/forms/expense-form.html b/examples/use-cases/expense-reimbursement/src/main/resources/static/forms/expense-form.html new file mode 100644 index 0000000..9d6a69d --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/main/resources/static/forms/expense-form.html @@ -0,0 +1,36 @@ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementDeploymentIT.java b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementDeploymentIT.java new file mode 100644 index 0000000..17862af --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementDeploymentIT.java @@ -0,0 +1,54 @@ +package org.operaton.examples.expensereimbursement; + +import org.junit.jupiter.api.Test; +import org.operaton.bpm.engine.IdentityService; +import org.operaton.bpm.engine.RepositoryService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Testcontainers +class ExpenseReimbursementDeploymentIT { + + @Container + @ServiceConnection + @SuppressWarnings("rawtypes") + static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine"); + + @Autowired RepositoryService repositoryService; + @Autowired IdentityService identityService; + + @Test + void processDeploysSuccessfully() { + long count = repositoryService.createProcessDefinitionQuery() + .processDefinitionKey("expense-reimbursement").count(); + assertThat(count).isGreaterThan(0); + } + + @Test + void decisionDeploysSuccessfully() { + long count = repositoryService.createDecisionDefinitionQuery() + .decisionDefinitionKey("reimbursement-approval").count(); + assertThat(count).isGreaterThan(0); + } + + @Test + void financeGroupIsSeeded() { + long count = identityService.createGroupQuery().groupId("finance").count(); + assertThat(count).isEqualTo(1); + } + + @Test + void financeUsersAreSeeded() { + long alice = identityService.createUserQuery().userId("alice").memberOfGroup("finance").count(); + long bob = identityService.createUserQuery().userId("bob").memberOfGroup("finance").count(); + assertThat(alice).isEqualTo(1); + assertThat(bob).isEqualTo(1); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementIT.java b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementIT.java new file mode 100644 index 0000000..3586154 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ExpenseReimbursementIT.java @@ -0,0 +1,199 @@ +package org.operaton.examples.expensereimbursement; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.operaton.bpm.engine.HistoryService; +import org.operaton.bpm.engine.RuntimeService; +import org.operaton.bpm.engine.TaskService; +import org.operaton.bpm.engine.history.HistoricProcessInstance; +import org.operaton.bpm.engine.runtime.ProcessInstance; +import org.operaton.bpm.engine.task.Task; +import org.operaton.bpm.engine.variable.Variables; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.web.client.RestTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.wiremock.integrations.testcontainers.WireMockContainer; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@SpringBootTest +@Testcontainers +@ContextConfiguration(initializers = ExpenseReimbursementIT.Initializer.class) +class ExpenseReimbursementIT { + + // Fake image bytes — WireMock matches on requesterName in the text portion, not the image + private static final byte[] FAKE_RECEIPT = "fake-receipt-image".getBytes(StandardCharsets.UTF_8); + + @Container + @ServiceConnection + @SuppressWarnings("rawtypes") + static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine"); + + @Container + static WireMockContainer wireMock = new WireMockContainer("wiremock/wiremock:3.5.4") + .withMappingFromResource("wiremock/mappings/llm-receipt-match.json") + .withMappingFromResource("wiremock/mappings/llm-receipt-unrelated.json") + .withMappingFromResource("wiremock/mappings/llm-receipt-match-overtier.json") + .withMappingFromResource("wiremock/mappings/llm-email-approved.json") + .withMappingFromResource("wiremock/mappings/llm-email-rejected.json"); + + @Container + @SuppressWarnings("rawtypes") + static GenericContainer mailpit = new GenericContainer<>("axllent/mailpit:latest") + .withExposedPorts(1025, 8025); + + static class Initializer implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext ctx) { + String wireMockBase = "http://" + wireMock.getHost() + ":" + wireMock.getMappedPort(8080); + TestPropertyValues.of( + "llm.base-url=" + wireMockBase, + "spring.mail.host=" + mailpit.getHost(), + "spring.mail.port=" + mailpit.getMappedPort(1025) + ).applyTo(ctx.getEnvironment()); + } + } + + @Autowired RuntimeService runtimeService; + @Autowired TaskService taskService; + @Autowired HistoryService historyService; + + private final RestTemplate rest = new RestTemplate(); + + @BeforeEach + void clearMailpit() { + rest.delete("http://" + mailpit.getHost() + ":" + mailpit.getMappedPort(8025) + "/api/v1/messages"); + } + + private ProcessInstance startExpense(String requesterName, String requesterEmail, + String kind, double statedCost, String reason) { + Map vars = new HashMap<>(); + vars.put("requesterName", requesterName); + vars.put("requesterEmail", requesterEmail); + vars.put("kind", kind); + vars.put("statedCost", statedCost); + vars.put("reason", reason); + vars.put("receipt", Variables.fileValue("receipt.jpg") + .file(FAKE_RECEIPT) + .mimeType("image/jpeg") + .create()); + return runtimeService.startProcessInstanceByKey("expense-reimbursement", vars); + } + + private HistoricProcessInstance historic(ProcessInstance pi) { + return historyService.createHistoricProcessInstanceQuery() + .processInstanceId(pi.getId()).singleResult(); + } + + private Object var(ProcessInstance pi, String name) { + var v = historyService.createHistoricVariableInstanceQuery() + .processInstanceId(pi.getId()).variableName(name).singleResult(); + return v != null ? v.getValue() : null; + } + + private int mailCount() { + Map r = rest.getForObject( + "http://" + mailpit.getHost() + ":" + mailpit.getMappedPort(8025) + "/api/v1/messages", + Map.class); + return r != null ? ((Number) r.get("total")).intValue() : 0; + } + + @Test + void happyPath_matchWithinTier_autoApproved() { + // MEALS €35 < €50 threshold — no approval needed + ProcessInstance pi = startExpense("Alice Berger", "alice@example.com", "MEALS", 35.0, "Team lunch"); + + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + HistoricProcessInstance h = historic(pi); + assertThat(h).isNotNull(); + assertThat(h.getEndActivityId()).isEqualTo("EndEvent_Reimbursed"); + }); + + assertThat(var(pi, "matchResult")).isEqualTo("MATCH"); + assertThat(var(pi, "approvalRequired")).isEqualTo(false); + assertThat(var(pi, "paymentReference")).asString().startsWith("PAY-"); + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertThat(mailCount()).isEqualTo(1)); + } + + @Test + void unrelatedReceipt_forcesApproval_thenApproved() { + // UNRELATED → approvalRequired=true regardless of kind/cost; approver approves + ProcessInstance pi = startExpense("Bob Richter", "bob@example.com", "TRAVEL", 150.0, "Conference travel"); + + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + Task task = taskService.createTaskQuery() + .processInstanceId(pi.getId()) + .taskDefinitionKey("UserTask_ApproveReimbursement") + .singleResult(); + assertThat(task).as("Approval task must exist").isNotNull(); + }); + + assertThat(var(pi, "matchResult")).isEqualTo("UNRELATED"); + assertThat(var(pi, "approvalRequired")).isEqualTo(true); + + Task task = taskService.createTaskQuery() + .processInstanceId(pi.getId()) + .taskDefinitionKey("UserTask_ApproveReimbursement") + .singleResult(); + taskService.complete(task.getId(), Map.of("approved", true)); + + await().atMost(Duration.ofSeconds(20)).untilAsserted(() -> { + HistoricProcessInstance h = historic(pi); + assertThat(h).isNotNull(); + assertThat(h.getEndActivityId()).isEqualTo("EndEvent_Reimbursed"); + }); + + assertThat(var(pi, "paymentReference")).asString().startsWith("PAY-"); + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertThat(mailCount()).isEqualTo(1)); + } + + @Test + void matchButOverTier_approvalRequired_thenRejected() { + // EQUIPMENT €1200 > €1000 threshold → approvalRequired=true; approver rejects + ProcessInstance pi = startExpense("Charlie Weiss", "charlie@example.com", "EQUIPMENT", 1200.0, "Laptop purchase"); + + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + Task task = taskService.createTaskQuery() + .processInstanceId(pi.getId()) + .taskDefinitionKey("UserTask_ApproveReimbursement") + .singleResult(); + assertThat(task).as("Approval task must exist").isNotNull(); + }); + + assertThat(var(pi, "matchResult")).isEqualTo("MATCH"); + assertThat(var(pi, "approvalRequired")).isEqualTo(true); + + Task task = taskService.createTaskQuery() + .processInstanceId(pi.getId()) + .taskDefinitionKey("UserTask_ApproveReimbursement") + .singleResult(); + taskService.complete(task.getId(), Map.of("approved", false)); + + await().atMost(Duration.ofSeconds(20)).untilAsserted(() -> { + HistoricProcessInstance h = historic(pi); + assertThat(h).isNotNull(); + assertThat(h.getEndActivityId()).isEqualTo("EndEvent_Rejected"); + }); + + assertThat(var(pi, "paymentReference")).isNull(); + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertThat(mailCount()).isEqualTo(1)); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/LlmPropertiesTest.java b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/LlmPropertiesTest.java new file mode 100644 index 0000000..3d4819b --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/LlmPropertiesTest.java @@ -0,0 +1,21 @@ +package org.operaton.examples.expensereimbursement; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class LlmPropertiesTest { + + @Test + void chatCompletionsUrl_appendsPath() { + LlmProperties props = new LlmProperties(); + props.setBaseUrl("http://localhost:11434"); + assertThat(props.getChatCompletionsUrl()).isEqualTo("http://localhost:11434/v1/chat/completions"); + } + + @Test + void authorizationHeader_usesBearerPrefix() { + LlmProperties props = new LlmProperties(); + props.setApiKey("my-key"); + assertThat(props.getAuthorizationHeader()).isEqualTo("Bearer my-key"); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/PromptBuilderTest.java b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/PromptBuilderTest.java new file mode 100644 index 0000000..1541132 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/PromptBuilderTest.java @@ -0,0 +1,60 @@ +package org.operaton.examples.expensereimbursement; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +class PromptBuilderTest { + + private PromptBuilder builder; + private ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + LlmProperties props = new LlmProperties(); + props.setModel("llama3.2-vision"); + props.setBaseUrl("http://localhost:11434"); + props.setApiKey("ollama"); + builder = new PromptBuilder(props, mapper); + } + + @Test + void receiptAnalysisRequest_containsRequesterNameInUserText() throws Exception { + String base64 = Base64.getEncoder().encodeToString("fake-image".getBytes()); + String json = builder.receiptAnalysisRequest(base64, "Alice Berger", 35.0, "MEALS"); + JsonNode root = mapper.readTree(json); + assertThat(root.path("model").asText()).isEqualTo("llama3.2-vision"); + // user message content is an array (vision format) + JsonNode userContent = root.path("messages").path(1).path("content"); + assertThat(userContent.isArray()).isTrue(); + boolean hasText = false; + for (JsonNode el : userContent) { + if ("text".equals(el.path("type").asText())) { + assertThat(el.path("text").asText()).contains("Requester: Alice Berger"); + hasText = true; + } + } + assertThat(hasText).isTrue(); + } + + @Test + void approvalEmailRequest_systemMentionsApprovedForReimbursement() throws Exception { + String json = builder.approvalEmailRequest("Alice Berger", 35.0, "MEALS", "Team lunch", "PAY-001"); + JsonNode root = mapper.readTree(json); + String systemContent = root.path("messages").path(0).path("content").asText(); + assertThat(systemContent).contains("approved for reimbursement"); + } + + @Test + void rejectionEmailRequest_systemMentionsRejected() throws Exception { + String json = builder.rejectionEmailRequest("Bob Richter", 150.0, "TRAVEL", "Conference trip"); + JsonNode root = mapper.readTree(json); + String systemContent = root.path("messages").path(0).path("content").asText(); + assertThat(systemContent).contains("rejected"); + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ResponseParserTest.java b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ResponseParserTest.java new file mode 100644 index 0000000..c620ffd --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/java/org/operaton/examples/expensereimbursement/ResponseParserTest.java @@ -0,0 +1,51 @@ +package org.operaton.examples.expensereimbursement; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResponseParserTest { + + private ResponseParser parser; + + @BeforeEach + void setUp() { + parser = new ResponseParser(new ObjectMapper()); + } + + @Test + void matchResult_parsesMatchCorrectly() { + String inner = "{\"matchResult\": \"MATCH\", \"extractedName\": \"Cafe\", \"extractedCost\": 12.5, \"analysisNotes\": \"ok\"}"; + String outer = buildResponse(inner); + assertThat(parser.matchResult(outer)).isEqualTo("MATCH"); + assertThat(parser.extractedName(outer)).isEqualTo("Cafe"); + assertThat(parser.extractedCost(outer)).isEqualTo(12.5); + } + + @Test + void matchResult_defaultsToUnrelatedOnInvalidJson() { + assertThat(parser.matchResult("not-json")).isEqualTo("UNRELATED"); + } + + @Test + void matchResult_defaultsToUnrelatedOnUnknownValue() { + String inner = "{\"matchResult\": \"UNKNOWN\"}"; + String outer = buildResponse(inner); + assertThat(parser.matchResult(outer)).isEqualTo("UNRELATED"); + } + + @Test + void emailBody_returnsRawContent() { + String outer = buildResponse("Hello, your expense is approved."); + assertThat(parser.emailBody(outer)).isEqualTo("Hello, your expense is approved."); + } + + private String buildResponse(String content) { + try { + String escaped = new ObjectMapper().createObjectNode().put("v", content).get("v").toString(); + return "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":" + escaped + "}}]}"; + } catch (Exception e) { throw new RuntimeException(e); } + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-email-approved.json b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-email-approved.json new file mode 100644 index 0000000..e6e255d --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-email-approved.json @@ -0,0 +1,20 @@ +{ + "priority": 10, + "request": { + "method": "POST", + "urlPath": "/v1/chat/completions", + "bodyPatterns": [{ "contains": "approved for reimbursement" }] + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { + "choices": [{ + "message": { + "role": "assistant", + "content": "Dear Requester,\n\nYour expense reimbursement has been approved and payment has been initiated.\n\nKind regards,\nFinance Team" + } + }] + } + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-email-rejected.json b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-email-rejected.json new file mode 100644 index 0000000..f5ef6c5 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-email-rejected.json @@ -0,0 +1,20 @@ +{ + "priority": 10, + "request": { + "method": "POST", + "urlPath": "/v1/chat/completions", + "bodyPatterns": [{ "contains": "reimbursement request has been rejected" }] + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { + "choices": [{ + "message": { + "role": "assistant", + "content": "Dear Requester,\n\nUnfortunately, your expense reimbursement request could not be approved at this time.\n\nKind regards,\nFinance Team" + } + }] + } + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-match-overtier.json b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-match-overtier.json new file mode 100644 index 0000000..ed4ebe8 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-match-overtier.json @@ -0,0 +1,20 @@ +{ + "priority": 5, + "request": { + "method": "POST", + "urlPath": "/v1/chat/completions", + "bodyPatterns": [{ "contains": "Requester: Charlie Weiss" }] + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { + "choices": [{ + "message": { + "role": "assistant", + "content": "{\"matchResult\": \"MATCH\", \"extractedName\": \"TechStore GmbH\", \"extractedCost\": 1200.0, \"analysisNotes\": \"Receipt confirms stated purchase amount.\"}" + } + }] + } + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-match.json b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-match.json new file mode 100644 index 0000000..158bf54 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-match.json @@ -0,0 +1,20 @@ +{ + "priority": 5, + "request": { + "method": "POST", + "urlPath": "/v1/chat/completions", + "bodyPatterns": [{ "contains": "Requester: Alice Berger" }] + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { + "choices": [{ + "message": { + "role": "assistant", + "content": "{\"matchResult\": \"MATCH\", \"extractedName\": \"Restaurant Bella Italia\", \"extractedCost\": 35.0, \"analysisNotes\": \"Receipt confirms stated amount and payee.\"}" + } + }] + } + } +} diff --git a/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-unrelated.json b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-unrelated.json new file mode 100644 index 0000000..0a06298 --- /dev/null +++ b/examples/use-cases/expense-reimbursement/src/test/resources/wiremock/mappings/llm-receipt-unrelated.json @@ -0,0 +1,20 @@ +{ + "priority": 5, + "request": { + "method": "POST", + "urlPath": "/v1/chat/completions", + "bodyPatterns": [{ "contains": "Requester: Bob Richter" }] + }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { + "choices": [{ + "message": { + "role": "assistant", + "content": "{\"matchResult\": \"UNRELATED\", \"extractedName\": \"\", \"extractedCost\": 0.0, \"analysisNotes\": \"Image does not appear to be a valid expense receipt.\"}" + } + }] + } + } +} diff --git a/pom.xml b/pom.xml index 7d15f16..b35775f 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,7 @@ examples/use-cases/employee-onboarding examples/use-cases/candidate-screening examples/use-cases/bank-account-opening + examples/use-cases/expense-reimbursement examples/use-cases/procurement-collaboration examples/use-cases/supply-chain-tracking examples/xslt-script-task