You need to sign in before continuing.

Commit 3706e2af376e0d8f696cc43877e9323037aaf1b5

Authored by zichun
1 parent 03814018

feat: rules engine write API + workflow user-task endpoints

- RuleEvaluator: pure ECA evaluator with 8 operators + AND/OR logic
- RuleController: PUT/DELETE on metadata__rule (user source only, with auth and RuleEngine.refresh)
- MetadataLoader/Controller: rule YAML loading + GET endpoints
- WorkflowService/Controller: list/get/complete user tasks
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt
@@ -154,6 +154,7 @@ class MetadataLoader( @@ -154,6 +154,7 @@ class MetadataLoader(
154 customFields = files.flatMap { it.parsed.customFields }, 154 customFields = files.flatMap { it.parsed.customFields },
155 forms = files.flatMap { it.parsed.forms }, 155 forms = files.flatMap { it.parsed.forms },
156 listViews = files.flatMap { it.parsed.listViews }, 156 listViews = files.flatMap { it.parsed.listViews },
  157 + rules = files.flatMap { it.parsed.rules },
157 ) 158 )
158 159
159 wipeBySource(source) 160 wipeBySource(source)
@@ -163,11 +164,12 @@ class MetadataLoader( @@ -163,11 +164,12 @@ class MetadataLoader(
163 insertCustomFields(source, merged) 164 insertCustomFields(source, merged)
164 insertForms(source, merged) 165 insertForms(source, merged)
165 insertListViews(source, merged) 166 insertListViews(source, merged)
  167 + insertRules(source, merged)
166 168
167 log.info( 169 log.info(
168 - "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields, {} forms, {} list views from {} file(s)", 170 + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields, {} forms, {} list views, {} rules from {} file(s)",
169 source, merged.entities.size, merged.permissions.size, merged.menus.size, 171 source, merged.entities.size, merged.permissions.size, merged.menus.size,
170 - merged.customFields.size, merged.forms.size, merged.listViews.size, files.size, 172 + merged.customFields.size, merged.forms.size, merged.listViews.size, merged.rules.size, files.size,
171 ) 173 )
172 174
173 return LoadResult( 175 return LoadResult(
@@ -178,6 +180,7 @@ class MetadataLoader( @@ -178,6 +180,7 @@ class MetadataLoader(
178 customFieldCount = merged.customFields.size, 180 customFieldCount = merged.customFields.size,
179 formCount = merged.forms.size, 181 formCount = merged.forms.size,
180 listViewCount = merged.listViews.size, 182 listViewCount = merged.listViews.size,
  183 + ruleCount = merged.rules.size,
181 files = files.map { it.url }, 184 files = files.map { it.url },
182 ) 185 )
183 } 186 }
@@ -194,6 +197,7 @@ class MetadataLoader( @@ -194,6 +197,7 @@ class MetadataLoader(
194 val customFieldCount: Int = 0, 197 val customFieldCount: Int = 0,
195 val formCount: Int = 0, 198 val formCount: Int = 0,
196 val listViewCount: Int = 0, 199 val listViewCount: Int = 0,
  200 + val ruleCount: Int = 0,
197 val files: List<String>, 201 val files: List<String>,
198 ) 202 )
199 203
@@ -207,6 +211,7 @@ class MetadataLoader( @@ -207,6 +211,7 @@ class MetadataLoader(
207 jdbc.update("DELETE FROM metadata__custom_field WHERE source = :source", params) 211 jdbc.update("DELETE FROM metadata__custom_field WHERE source = :source", params)
208 jdbc.update("DELETE FROM metadata__form WHERE source = :source", params) 212 jdbc.update("DELETE FROM metadata__form WHERE source = :source", params)
209 jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params) 213 jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params)
  214 + jdbc.update("DELETE FROM metadata__rule WHERE source = :source", params)
210 } 215 }
211 216
212 private fun insertEntities(source: String, file: MetadataYamlFile) { 217 private fun insertEntities(source: String, file: MetadataYamlFile) {
@@ -311,6 +316,23 @@ class MetadataLoader( @@ -311,6 +316,23 @@ class MetadataLoader(
311 } 316 }
312 } 317 }
313 318
  319 + private fun insertRules(source: String, file: MetadataYamlFile) {
  320 + val now = Timestamp.from(Instant.now())
  321 + for (rule in file.rules) {
  322 + jdbc.update(
  323 + """
  324 + INSERT INTO metadata__rule (id, source, payload, created_at, updated_at)
  325 + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)
  326 + """.trimIndent(),
  327 + MapSqlParameterSource()
  328 + .addValue("id", UUID.randomUUID())
  329 + .addValue("source", source)
  330 + .addValue("payload", jsonMapper.writeValueAsString(rule))
  331 + .addValue("now", now),
  332 + )
  333 + }
  334 + }
  335 +
314 private data class ParsedYaml( 336 private data class ParsedYaml(
315 val url: String, 337 val url: String,
316 val parsed: MetadataYamlFile, 338 val parsed: MetadataYamlFile,
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluator.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.rules
  2 +
  3 +/**
  4 + * Stateless evaluator for event-condition-action rule conditions.
  5 + *
  6 + * Takes a flattened event map and a list of conditions, returns whether the
  7 + * event matches. No Spring dependencies -- this is a pure function so it can
  8 + * be unit-tested in isolation without any framework context.
  9 + */
  10 +object RuleEvaluator {
  11 +
  12 + data class Condition(val field: String, val operator: String, val value: String)
  13 +
  14 + /**
  15 + * Evaluate a list of conditions against an event map.
  16 + *
  17 + * @param eventMap flattened key-value representation of the domain event
  18 + * @param conditions the conditions to test
  19 + * @param logic "AND" (all must match) or "OR" (any must match); defaults to AND
  20 + * @return true if the event satisfies the condition set
  21 + */
  22 + fun evaluate(eventMap: Map<String, Any?>, conditions: List<Condition>, logic: String): Boolean {
  23 + if (conditions.isEmpty()) return true
  24 + return if (logic.uppercase() == "OR") {
  25 + conditions.any { matches(eventMap, it) }
  26 + } else {
  27 + conditions.all { matches(eventMap, it) }
  28 + }
  29 + }
  30 +
  31 + private fun matches(eventMap: Map<String, Any?>, c: Condition): Boolean {
  32 + val actual = eventMap[c.field]
  33 + ?: return c.operator == "eq" && c.value.isBlank()
  34 + val av = actual.toString()
  35 + return when (c.operator) {
  36 + "eq" -> av == c.value
  37 + "neq" -> av != c.value
  38 + "gt" -> av.toDoubleOrNull()?.let { it > c.value.toDouble() } ?: false
  39 + "gte" -> av.toDoubleOrNull()?.let { it >= c.value.toDouble() } ?: false
  40 + "lt" -> av.toDoubleOrNull()?.let { it < c.value.toDouble() } ?: false
  41 + "lte" -> av.toDoubleOrNull()?.let { it <= c.value.toDouble() } ?: false
  42 + "contains" -> av.contains(c.value, ignoreCase = true)
  43 + "in" -> c.value.split(",").map { it.trim() }.contains(av)
  44 + else -> false
  45 + }
  46 + }
  47 +}
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt
@@ -45,6 +45,7 @@ class MetadataController( @@ -45,6 +45,7 @@ class MetadataController(
45 "customFields" to readPayloads("metadata__custom_field"), 45 "customFields" to readPayloads("metadata__custom_field"),
46 "forms" to readPayloads("metadata__form"), 46 "forms" to readPayloads("metadata__form"),
47 "listViews" to readPayloads("metadata__list_view"), 47 "listViews" to readPayloads("metadata__list_view"),
  48 + "rules" to readPayloads("metadata__rule"),
48 ) 49 )
49 50
50 @GetMapping("/entities") 51 @GetMapping("/entities")
@@ -72,6 +73,14 @@ class MetadataController( @@ -72,6 +73,14 @@ class MetadataController(
72 readPayloadBySlug("metadata__list_view", slug) 73 readPayloadBySlug("metadata__list_view", slug)
73 ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found") 74 ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found")
74 75
  76 + @GetMapping("/rules")
  77 + fun rules(): List<Map<String, Any?>> = readPayloads("metadata__rule")
  78 +
  79 + @GetMapping("/rules/{slug}")
  80 + fun ruleBySlug(@PathVariable slug: String): Map<String, Any?> =
  81 + readPayloadBySlug("metadata__rule", slug)
  82 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rule '$slug' not found")
  83 +
75 /** 84 /**
76 * Read every custom-field declaration as raw `metadata__custom_field` 85 * Read every custom-field declaration as raw `metadata__custom_field`
77 * payload rows. Returns the YAML wire format unchanged so the SPA 86 * payload rows. Returns the YAML wire format unchanged so the SPA
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/RuleController.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.web
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import org.springframework.http.HttpStatus
  5 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  6 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  7 +import org.springframework.web.bind.annotation.DeleteMapping
  8 +import org.springframework.web.bind.annotation.PathVariable
  9 +import org.springframework.web.bind.annotation.PutMapping
  10 +import org.springframework.web.bind.annotation.RequestBody
  11 +import org.springframework.web.bind.annotation.RequestMapping
  12 +import org.springframework.web.bind.annotation.ResponseStatus
  13 +import org.springframework.web.bind.annotation.RestController
  14 +import org.springframework.web.server.ResponseStatusException
  15 +import org.vibeerp.platform.metadata.rules.RuleEngine
  16 +import org.vibeerp.platform.security.authz.RequirePermission
  17 +import java.sql.Timestamp
  18 +import java.time.Instant
  19 +import java.util.UUID
  20 +
  21 +/**
  22 + * Write endpoints for automation rules stored in `metadata__rule`.
  23 + *
  24 + * Only rows with `source = 'user'` may be created, updated, or deleted
  25 + * through this controller. Rows seeded by core YAMLs or plug-in JARs
  26 + * (`source = 'core'` or `source = 'plugin:*'`) are immutable from the
  27 + * REST surface -- they can only change by redeploying the owning module.
  28 + *
  29 + * After every successful mutation the [RuleEngine] is refreshed so that
  30 + * new/changed rules take effect immediately without a restart.
  31 + *
  32 + * Read endpoints live in [MetadataController].
  33 + */
  34 +@RestController
  35 +@RequestMapping("/api/v1/_meta/metadata/rules")
  36 +class RuleController(
  37 + private val jdbc: NamedParameterJdbcTemplate,
  38 + private val objectMapper: ObjectMapper,
  39 + private val ruleEngine: RuleEngine,
  40 +) {
  41 +
  42 + @PutMapping("/{slug}")
  43 + @RequirePermission("admin.metadata.write")
  44 + fun upsert(@PathVariable slug: String, @RequestBody body: Map<String, Any?>): Map<String, Any?> {
  45 + val payload = body.toMutableMap().apply { put("slug", slug) }
  46 + val payloadJson = objectMapper.writeValueAsString(payload)
  47 + val now = Timestamp.from(Instant.now())
  48 +
  49 + val existing = jdbc.query(
  50 + "SELECT id, source FROM metadata__rule WHERE payload->>'slug' = :slug",
  51 + MapSqlParameterSource("slug", slug),
  52 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  53 +
  54 + if (existing.isNotEmpty()) {
  55 + val (id, source) = existing.first()
  56 + if (source != "user") {
  57 + throw ResponseStatusException(
  58 + HttpStatus.FORBIDDEN,
  59 + "Cannot modify rule '$slug' (source='$source')",
  60 + )
  61 + }
  62 + jdbc.update(
  63 + "UPDATE metadata__rule SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)",
  64 + MapSqlParameterSource()
  65 + .addValue("id", id)
  66 + .addValue("payload", payloadJson)
  67 + .addValue("now", now),
  68 + )
  69 + } else {
  70 + jdbc.update(
  71 + "INSERT INTO metadata__rule (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)",
  72 + MapSqlParameterSource()
  73 + .addValue("id", UUID.randomUUID())
  74 + .addValue("payload", payloadJson)
  75 + .addValue("now", now),
  76 + )
  77 + }
  78 +
  79 + ruleEngine.refresh()
  80 + return payload + mapOf("source" to "user")
  81 + }
  82 +
  83 + @DeleteMapping("/{slug}")
  84 + @RequirePermission("admin.metadata.write")
  85 + @ResponseStatus(HttpStatus.NO_CONTENT)
  86 + fun delete(@PathVariable slug: String) {
  87 + val existing = jdbc.query(
  88 + "SELECT id, source FROM metadata__rule WHERE payload->>'slug' = :slug",
  89 + MapSqlParameterSource("slug", slug),
  90 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  91 +
  92 + if (existing.isEmpty()) {
  93 + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Rule '$slug' not found")
  94 + }
  95 + val (id, source) = existing.first()
  96 + if (source != "user") {
  97 + throw ResponseStatusException(
  98 + HttpStatus.FORBIDDEN,
  99 + "Cannot delete rule '$slug' (source='$source')",
  100 + )
  101 + }
  102 + jdbc.update(
  103 + "DELETE FROM metadata__rule WHERE id = CAST(:id AS uuid)",
  104 + MapSqlParameterSource("id", id),
  105 + )
  106 +
  107 + ruleEngine.refresh()
  108 + }
  109 +}
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/rules/RuleEvaluatorTest.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.rules
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isFalse
  5 +import assertk.assertions.isTrue
  6 +import org.junit.jupiter.api.Test
  7 +
  8 +class RuleEvaluatorTest {
  9 +
  10 + private fun cond(field: String, op: String, value: String) =
  11 + RuleEvaluator.Condition(field, op, value)
  12 +
  13 + // ── eq ────────────────────────────────────────────────────────────
  14 +
  15 + @Test
  16 + fun `eq matches when equal`() {
  17 + val event = mapOf("status" to "CONFIRMED")
  18 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "eq", "CONFIRMED")), "AND")).isTrue()
  19 + }
  20 +
  21 + @Test
  22 + fun `eq fails when not equal`() {
  23 + val event = mapOf("status" to "DRAFT")
  24 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "eq", "CONFIRMED")), "AND")).isFalse()
  25 + }
  26 +
  27 + @Test
  28 + fun `eq with blank value matches missing field`() {
  29 + val event = mapOf("status" to "DRAFT")
  30 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("missing", "eq", "")), "AND")).isTrue()
  31 + }
  32 +
  33 + // ── neq ───────────────────────────────────────────────────────────
  34 +
  35 + @Test
  36 + fun `neq matches when not equal`() {
  37 + val event = mapOf("status" to "DRAFT")
  38 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "neq", "CONFIRMED")), "AND")).isTrue()
  39 + }
  40 +
  41 + @Test
  42 + fun `neq fails when equal`() {
  43 + val event = mapOf("status" to "CONFIRMED")
  44 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "neq", "CONFIRMED")), "AND")).isFalse()
  45 + }
  46 +
  47 + // ── numeric comparisons ──────────────────────────────────────────
  48 +
  49 + @Test
  50 + fun `gt matches when greater`() {
  51 + val event = mapOf<String, Any?>("amount" to 150.0)
  52 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "gt", "100")), "AND")).isTrue()
  53 + }
  54 +
  55 + @Test
  56 + fun `gt fails when equal`() {
  57 + val event = mapOf<String, Any?>("amount" to 100.0)
  58 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "gt", "100")), "AND")).isFalse()
  59 + }
  60 +
  61 + @Test
  62 + fun `gte matches when equal`() {
  63 + val event = mapOf<String, Any?>("amount" to 100.0)
  64 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "gte", "100")), "AND")).isTrue()
  65 + }
  66 +
  67 + @Test
  68 + fun `lt matches when less`() {
  69 + val event = mapOf<String, Any?>("amount" to 50.0)
  70 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "lt", "100")), "AND")).isTrue()
  71 + }
  72 +
  73 + @Test
  74 + fun `lt fails when equal`() {
  75 + val event = mapOf<String, Any?>("amount" to 100.0)
  76 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "lt", "100")), "AND")).isFalse()
  77 + }
  78 +
  79 + @Test
  80 + fun `lte matches when equal`() {
  81 + val event = mapOf<String, Any?>("amount" to 100.0)
  82 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "lte", "100")), "AND")).isTrue()
  83 + }
  84 +
  85 + @Test
  86 + fun `lte matches when less`() {
  87 + val event = mapOf<String, Any?>("amount" to 50.0)
  88 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "lte", "100")), "AND")).isTrue()
  89 + }
  90 +
  91 + @Test
  92 + fun `numeric comparison fails for non-numeric actual value`() {
  93 + val event = mapOf<String, Any?>("amount" to "not-a-number")
  94 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("amount", "gt", "100")), "AND")).isFalse()
  95 + }
  96 +
  97 + // ── contains ─────────────────────────────────────────────────────
  98 +
  99 + @Test
  100 + fun `contains matches case-insensitive substring`() {
  101 + val event = mapOf<String, Any?>("name" to "Premium Paper Stock")
  102 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("name", "contains", "paper")), "AND")).isTrue()
  103 + }
  104 +
  105 + @Test
  106 + fun `contains fails when substring absent`() {
  107 + val event = mapOf<String, Any?>("name" to "Premium Ink")
  108 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("name", "contains", "paper")), "AND")).isFalse()
  109 + }
  110 +
  111 + // ── in ────────────────────────────────────────────────────────────
  112 +
  113 + @Test
  114 + fun `in matches when value is in comma-separated list`() {
  115 + val event = mapOf<String, Any?>("status" to "CONFIRMED")
  116 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "in", "DRAFT, CONFIRMED, SHIPPED")), "AND")).isTrue()
  117 + }
  118 +
  119 + @Test
  120 + fun `in fails when value is not in list`() {
  121 + val event = mapOf<String, Any?>("status" to "CANCELLED")
  122 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "in", "DRAFT, CONFIRMED, SHIPPED")), "AND")).isFalse()
  123 + }
  124 +
  125 + // ── logic ─────────────────────────────────────────────────────────
  126 +
  127 + @Test
  128 + fun `AND logic requires all conditions to match`() {
  129 + val event = mapOf<String, Any?>("status" to "CONFIRMED", "amount" to 200.0)
  130 + val conditions = listOf(
  131 + cond("status", "eq", "CONFIRMED"),
  132 + cond("amount", "gt", "100"),
  133 + )
  134 + assertThat(RuleEvaluator.evaluate(event, conditions, "AND")).isTrue()
  135 + }
  136 +
  137 + @Test
  138 + fun `AND logic fails when one condition does not match`() {
  139 + val event = mapOf<String, Any?>("status" to "DRAFT", "amount" to 200.0)
  140 + val conditions = listOf(
  141 + cond("status", "eq", "CONFIRMED"),
  142 + cond("amount", "gt", "100"),
  143 + )
  144 + assertThat(RuleEvaluator.evaluate(event, conditions, "AND")).isFalse()
  145 + }
  146 +
  147 + @Test
  148 + fun `OR logic passes when any condition matches`() {
  149 + val event = mapOf<String, Any?>("status" to "DRAFT", "amount" to 200.0)
  150 + val conditions = listOf(
  151 + cond("status", "eq", "CONFIRMED"),
  152 + cond("amount", "gt", "100"),
  153 + )
  154 + assertThat(RuleEvaluator.evaluate(event, conditions, "OR")).isTrue()
  155 + }
  156 +
  157 + @Test
  158 + fun `OR logic fails when no conditions match`() {
  159 + val event = mapOf<String, Any?>("status" to "DRAFT", "amount" to 50.0)
  160 + val conditions = listOf(
  161 + cond("status", "eq", "CONFIRMED"),
  162 + cond("amount", "gt", "100"),
  163 + )
  164 + assertThat(RuleEvaluator.evaluate(event, conditions, "OR")).isFalse()
  165 + }
  166 +
  167 + // ── edge cases ────────────────────────────────────────────────────
  168 +
  169 + @Test
  170 + fun `empty conditions returns true`() {
  171 + val event = mapOf<String, Any?>("status" to "ANYTHING")
  172 + assertThat(RuleEvaluator.evaluate(event, emptyList(), "AND")).isTrue()
  173 + }
  174 +
  175 + @Test
  176 + fun `missing field returns false for non-eq operator`() {
  177 + val event = mapOf<String, Any?>("other" to "value")
  178 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("missing", "gt", "100")), "AND")).isFalse()
  179 + }
  180 +
  181 + @Test
  182 + fun `unknown operator returns false`() {
  183 + val event = mapOf<String, Any?>("status" to "CONFIRMED")
  184 + assertThat(RuleEvaluator.evaluate(event, listOf(cond("status", "regex", ".*")), "AND")).isFalse()
  185 + }
  186 +}
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt
@@ -231,4 +231,38 @@ class MetadataYamlParseTest { @@ -231,4 +231,38 @@ class MetadataYamlParseTest {
231 assertThat(lv.filters[0].operator).isEqualTo("eq") 231 assertThat(lv.filters[0].operator).isEqualTo("eq")
232 assertThat(lv.filters[1].operator).isEqualTo("contains") 232 assertThat(lv.filters[1].operator).isEqualTo("contains")
233 } 233 }
  234 +
  235 + @Test
  236 + fun `rules section parses with conditions and actions`() {
  237 + val yaml = """
  238 + rules:
  239 + - slug: high-value-order
  240 + name: High Value Order Alert
  241 + enabled: true
  242 + triggerEvent: SalesOrderConfirmedEvent
  243 + conditionLogic: AND
  244 + conditions:
  245 + - field: totalAmount
  246 + operator: gt
  247 + value: "10000"
  248 + actions:
  249 + - type: log
  250 + config:
  251 + message: "High value order detected"
  252 + """.trimIndent()
  253 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  254 + assertThat(parsed.rules).hasSize(1)
  255 + assertThat(parsed.rules[0].slug).isEqualTo("high-value-order")
  256 + assertThat(parsed.rules[0].name).isEqualTo("High Value Order Alert")
  257 + assertThat(parsed.rules[0].enabled).isEqualTo(true)
  258 + assertThat(parsed.rules[0].triggerEvent).isEqualTo("SalesOrderConfirmedEvent")
  259 + assertThat(parsed.rules[0].conditionLogic).isEqualTo("AND")
  260 + assertThat(parsed.rules[0].conditions).hasSize(1)
  261 + assertThat(parsed.rules[0].conditions[0].field).isEqualTo("totalAmount")
  262 + assertThat(parsed.rules[0].conditions[0].operator).isEqualTo("gt")
  263 + assertThat(parsed.rules[0].conditions[0].value).isEqualTo("10000")
  264 + assertThat(parsed.rules[0].actions).hasSize(1)
  265 + assertThat(parsed.rules[0].actions[0].type).isEqualTo("log")
  266 + assertThat(parsed.rules[0].actions[0].config["message"]).isEqualTo("High value order detected")
  267 + }
234 } 268 }
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/WorkflowService.kt
@@ -2,10 +2,14 @@ package org.vibeerp.platform.workflow @@ -2,10 +2,14 @@ package org.vibeerp.platform.workflow
2 2
3 import org.flowable.engine.RepositoryService 3 import org.flowable.engine.RepositoryService
4 import org.flowable.engine.RuntimeService 4 import org.flowable.engine.RuntimeService
  5 +import org.flowable.engine.TaskService
5 import org.flowable.engine.runtime.ProcessInstance 6 import org.flowable.engine.runtime.ProcessInstance
6 import org.slf4j.LoggerFactory 7 import org.slf4j.LoggerFactory
  8 +import org.springframework.http.HttpStatus
7 import org.springframework.stereotype.Service 9 import org.springframework.stereotype.Service
  10 +import org.springframework.web.server.ResponseStatusException
8 import org.vibeerp.platform.security.authz.AuthorizationContext 11 import org.vibeerp.platform.security.authz.AuthorizationContext
  12 +import java.time.Instant
9 13
10 /** 14 /**
11 * Thin facade over Flowable's [RuntimeService] + [RepositoryService] used 15 * Thin facade over Flowable's [RuntimeService] + [RepositoryService] used
@@ -26,6 +30,7 @@ import org.vibeerp.platform.security.authz.AuthorizationContext @@ -26,6 +30,7 @@ import org.vibeerp.platform.security.authz.AuthorizationContext
26 class WorkflowService( 30 class WorkflowService(
27 private val runtimeService: RuntimeService, 31 private val runtimeService: RuntimeService,
28 private val repositoryService: RepositoryService, 32 private val repositoryService: RepositoryService,
  33 + private val taskService: TaskService,
29 ) { 34 ) {
30 private val log = LoggerFactory.getLogger(WorkflowService::class.java) 35 private val log = LoggerFactory.getLogger(WorkflowService::class.java)
31 36
@@ -139,6 +144,66 @@ class WorkflowService( @@ -139,6 +144,66 @@ class WorkflowService(
139 ) 144 )
140 } 145 }
141 146
  147 + /* ------------------------------------------------------------------ */
  148 + /* User-task management */
  149 + /* ------------------------------------------------------------------ */
  150 +
  151 + /**
  152 + * List all active (pending) user tasks across all process instances,
  153 + * ordered newest first.
  154 + */
  155 + fun listPendingTasks(): List<UserTaskSummary> =
  156 + taskService.createTaskQuery().active().orderByTaskCreateTime().desc().list().map { task ->
  157 + val defKey = repositoryService
  158 + .getProcessDefinition(task.processDefinitionId).key
  159 + UserTaskSummary(
  160 + taskId = task.id,
  161 + taskName = task.name ?: "",
  162 + formKey = task.formKey,
  163 + processDefinitionKey = defKey,
  164 + processInstanceId = task.processInstanceId,
  165 + createTime = task.createTime.toInstant(),
  166 + assignee = task.assignee,
  167 + )
  168 + }
  169 +
  170 + /**
  171 + * Fetch a single user task's detail including its process variables
  172 + * (with framework-internal `__vibeerp_*` variables stripped).
  173 + *
  174 + * @throws ResponseStatusException 404 when the task does not exist.
  175 + */
  176 + fun getTaskDetail(taskId: String): UserTaskDetail {
  177 + val task = taskService.createTaskQuery().taskId(taskId).singleResult()
  178 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found")
  179 + val defKey = repositoryService
  180 + .getProcessDefinition(task.processDefinitionId).key
  181 + val variables = runtimeService.getVariables(task.executionId)
  182 + .filterKeys { !it.startsWith(DispatchingJavaDelegate.RESERVED_VAR_PREFIX) }
  183 + return UserTaskDetail(
  184 + taskId = task.id,
  185 + taskName = task.name ?: "",
  186 + formKey = task.formKey,
  187 + variables = variables,
  188 + processDefinitionKey = defKey,
  189 + processInstanceId = task.processInstanceId,
  190 + createTime = task.createTime.toInstant(),
  191 + )
  192 + }
  193 +
  194 + /**
  195 + * Complete a user task, optionally passing variables into the process
  196 + * scope. Null-valued entries are dropped before forwarding to Flowable.
  197 + *
  198 + * @throws ResponseStatusException 404 when the task does not exist.
  199 + */
  200 + fun completeTask(taskId: String, variables: Map<String, Any?>) {
  201 + taskService.createTaskQuery().taskId(taskId).singleResult()
  202 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task '$taskId' not found")
  203 + taskService.complete(taskId, variables.filterValues { it != null })
  204 + log.info("completed user task '{}'", taskId)
  205 + }
  206 +
142 companion object { 207 companion object {
143 /** 208 /**
144 * Reserved process variable name: the UUID-string id of the 209 * Reserved process variable name: the UUID-string id of the
@@ -178,3 +243,23 @@ data class ProcessDefinitionSummary( @@ -178,3 +243,23 @@ data class ProcessDefinitionSummary(
178 val deploymentId: String, 243 val deploymentId: String,
179 val resourceName: String, 244 val resourceName: String,
180 ) 245 )
  246 +
  247 +data class UserTaskSummary(
  248 + val taskId: String,
  249 + val taskName: String,
  250 + val formKey: String?,
  251 + val processDefinitionKey: String,
  252 + val processInstanceId: String,
  253 + val createTime: Instant,
  254 + val assignee: String?,
  255 +)
  256 +
  257 +data class UserTaskDetail(
  258 + val taskId: String,
  259 + val taskName: String,
  260 + val formKey: String?,
  261 + val variables: Map<String, Any?>,
  262 + val processDefinitionKey: String,
  263 + val processInstanceId: String,
  264 + val createTime: Instant,
  265 +)
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/http/WorkflowController.kt
@@ -11,11 +11,14 @@ import org.springframework.web.bind.annotation.PostMapping @@ -11,11 +11,14 @@ import org.springframework.web.bind.annotation.PostMapping
11 import org.springframework.web.bind.annotation.RequestBody 11 import org.springframework.web.bind.annotation.RequestBody
12 import org.springframework.web.bind.annotation.RequestMapping 12 import org.springframework.web.bind.annotation.RequestMapping
13 import org.springframework.web.bind.annotation.RestController 13 import org.springframework.web.bind.annotation.RestController
  14 +import org.springframework.web.bind.annotation.ResponseStatus
14 import org.vibeerp.platform.security.authz.RequirePermission 15 import org.vibeerp.platform.security.authz.RequirePermission
15 import org.vibeerp.platform.workflow.ProcessDefinitionSummary 16 import org.vibeerp.platform.workflow.ProcessDefinitionSummary
16 import org.vibeerp.platform.workflow.ProcessInstanceSummary 17 import org.vibeerp.platform.workflow.ProcessInstanceSummary
17 import org.vibeerp.platform.workflow.StartedProcessInstance 18 import org.vibeerp.platform.workflow.StartedProcessInstance
18 import org.vibeerp.platform.workflow.TaskHandlerRegistry 19 import org.vibeerp.platform.workflow.TaskHandlerRegistry
  20 +import org.vibeerp.platform.workflow.UserTaskDetail
  21 +import org.vibeerp.platform.workflow.UserTaskSummary
19 import org.vibeerp.platform.workflow.WorkflowService 22 import org.vibeerp.platform.workflow.WorkflowService
20 23
21 /** 24 /**
@@ -26,6 +29,9 @@ import org.vibeerp.platform.workflow.WorkflowService @@ -26,6 +29,9 @@ import org.vibeerp.platform.workflow.WorkflowService
26 * - `GET /api/v1/workflow/process-instances/{id}/variables` — inspect vars 29 * - `GET /api/v1/workflow/process-instances/{id}/variables` — inspect vars
27 * - `GET /api/v1/workflow/definitions` — list deployed definitions 30 * - `GET /api/v1/workflow/definitions` — list deployed definitions
28 * - `GET /api/v1/workflow/handlers` — list registered TaskHandler keys 31 * - `GET /api/v1/workflow/handlers` — list registered TaskHandler keys
  32 + * - `GET /api/v1/workflow/tasks` — list pending user tasks
  33 + * - `GET /api/v1/workflow/tasks/{taskId}` — inspect a single user task
  34 + * - `POST /api/v1/workflow/tasks/{taskId}/complete` — complete a user task
29 * 35 *
30 * The endpoints are permission-gated using the same 36 * The endpoints are permission-gated using the same
31 * [org.vibeerp.platform.security.authz.RequirePermission] aspect every PBC 37 * [org.vibeerp.platform.security.authz.RequirePermission] aspect every PBC
@@ -69,6 +75,28 @@ class WorkflowController( @@ -69,6 +75,28 @@ class WorkflowController(
69 keys = handlerRegistry.keys().sorted(), 75 keys = handlerRegistry.keys().sorted(),
70 ) 76 )
71 77
  78 + /* ------------------------------------------------------------------ */
  79 + /* User-task endpoints */
  80 + /* ------------------------------------------------------------------ */
  81 +
  82 + @GetMapping("/tasks")
  83 + @RequirePermission("workflow.task.read")
  84 + fun listTasks(): List<UserTaskSummary> = workflowService.listPendingTasks()
  85 +
  86 + @GetMapping("/tasks/{taskId}")
  87 + @RequirePermission("workflow.task.read")
  88 + fun getTask(@PathVariable taskId: String): UserTaskDetail = workflowService.getTaskDetail(taskId)
  89 +
  90 + @PostMapping("/tasks/{taskId}/complete")
  91 + @RequirePermission("workflow.task.complete")
  92 + @ResponseStatus(HttpStatus.NO_CONTENT)
  93 + fun completeTask(
  94 + @PathVariable taskId: String,
  95 + @RequestBody variables: Map<String, Any?>,
  96 + ) {
  97 + workflowService.completeTask(taskId, variables)
  98 + }
  99 +
72 @ExceptionHandler(NoSuchElementException::class, FlowableObjectNotFoundException::class) 100 @ExceptionHandler(NoSuchElementException::class, FlowableObjectNotFoundException::class)
73 fun handleMissing(ex: Exception): ResponseEntity<ErrorResponse> = 101 fun handleMissing(ex: Exception): ResponseEntity<ErrorResponse> =
74 ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse(message = ex.message ?: "not found")) 102 ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse(message = ex.message ?: "not found"))
platform/platform-workflow/src/main/resources/META-INF/vibe-erp/metadata/workflow.yml
@@ -9,6 +9,10 @@ permissions: @@ -9,6 +9,10 @@ permissions:
9 description: Read active workflow process instances and their variables 9 description: Read active workflow process instances and their variables
10 - key: workflow.definition.read 10 - key: workflow.definition.read
11 description: Read deployed BPMN process definitions and registered task handlers 11 description: Read deployed BPMN process definitions and registered task handlers
  12 + - key: workflow.task.read
  13 + description: View pending user tasks
  14 + - key: workflow.task.complete
  15 + description: Complete a pending user task
12 16
13 menus: 17 menus:
14 - path: /workflow/processes 18 - path: /workflow/processes
@@ -21,3 +25,8 @@ menus: @@ -21,3 +25,8 @@ menus:
21 icon: file-code 25 icon: file-code
22 section: Workflow 26 section: Workflow
23 order: 710 27 order: 710
  28 + - path: /workflow/tasks
  29 + label: Tasks
  30 + icon: clipboard-check
  31 + section: Workflow
  32 + order: 850