Commit 3706e2af376e0d8f696cc43877e9323037aaf1b5
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
Showing
9 changed files
with
531 additions
and
2 deletions
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt
| ... | ... | @@ -154,6 +154,7 @@ class MetadataLoader( |
| 154 | 154 | customFields = files.flatMap { it.parsed.customFields }, |
| 155 | 155 | forms = files.flatMap { it.parsed.forms }, |
| 156 | 156 | listViews = files.flatMap { it.parsed.listViews }, |
| 157 | + rules = files.flatMap { it.parsed.rules }, | |
| 157 | 158 | ) |
| 158 | 159 | |
| 159 | 160 | wipeBySource(source) |
| ... | ... | @@ -163,11 +164,12 @@ class MetadataLoader( |
| 163 | 164 | insertCustomFields(source, merged) |
| 164 | 165 | insertForms(source, merged) |
| 165 | 166 | insertListViews(source, merged) |
| 167 | + insertRules(source, merged) | |
| 166 | 168 | |
| 167 | 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 | 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 | 175 | return LoadResult( |
| ... | ... | @@ -178,6 +180,7 @@ class MetadataLoader( |
| 178 | 180 | customFieldCount = merged.customFields.size, |
| 179 | 181 | formCount = merged.forms.size, |
| 180 | 182 | listViewCount = merged.listViews.size, |
| 183 | + ruleCount = merged.rules.size, | |
| 181 | 184 | files = files.map { it.url }, |
| 182 | 185 | ) |
| 183 | 186 | } |
| ... | ... | @@ -194,6 +197,7 @@ class MetadataLoader( |
| 194 | 197 | val customFieldCount: Int = 0, |
| 195 | 198 | val formCount: Int = 0, |
| 196 | 199 | val listViewCount: Int = 0, |
| 200 | + val ruleCount: Int = 0, | |
| 197 | 201 | val files: List<String>, |
| 198 | 202 | ) |
| 199 | 203 | |
| ... | ... | @@ -207,6 +211,7 @@ class MetadataLoader( |
| 207 | 211 | jdbc.update("DELETE FROM metadata__custom_field WHERE source = :source", params) |
| 208 | 212 | jdbc.update("DELETE FROM metadata__form WHERE source = :source", params) |
| 209 | 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 | 217 | private fun insertEntities(source: String, file: MetadataYamlFile) { |
| ... | ... | @@ -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 | 336 | private data class ParsedYaml( |
| 315 | 337 | val url: String, |
| 316 | 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 | 45 | "customFields" to readPayloads("metadata__custom_field"), |
| 46 | 46 | "forms" to readPayloads("metadata__form"), |
| 47 | 47 | "listViews" to readPayloads("metadata__list_view"), |
| 48 | + "rules" to readPayloads("metadata__rule"), | |
| 48 | 49 | ) |
| 49 | 50 | |
| 50 | 51 | @GetMapping("/entities") |
| ... | ... | @@ -72,6 +73,14 @@ class MetadataController( |
| 72 | 73 | readPayloadBySlug("metadata__list_view", slug) |
| 73 | 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 | 85 | * Read every custom-field declaration as raw `metadata__custom_field` |
| 77 | 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 | 231 | assertThat(lv.filters[0].operator).isEqualTo("eq") |
| 232 | 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 | 2 | |
| 3 | 3 | import org.flowable.engine.RepositoryService |
| 4 | 4 | import org.flowable.engine.RuntimeService |
| 5 | +import org.flowable.engine.TaskService | |
| 5 | 6 | import org.flowable.engine.runtime.ProcessInstance |
| 6 | 7 | import org.slf4j.LoggerFactory |
| 8 | +import org.springframework.http.HttpStatus | |
| 7 | 9 | import org.springframework.stereotype.Service |
| 10 | +import org.springframework.web.server.ResponseStatusException | |
| 8 | 11 | import org.vibeerp.platform.security.authz.AuthorizationContext |
| 12 | +import java.time.Instant | |
| 9 | 13 | |
| 10 | 14 | /** |
| 11 | 15 | * Thin facade over Flowable's [RuntimeService] + [RepositoryService] used |
| ... | ... | @@ -26,6 +30,7 @@ import org.vibeerp.platform.security.authz.AuthorizationContext |
| 26 | 30 | class WorkflowService( |
| 27 | 31 | private val runtimeService: RuntimeService, |
| 28 | 32 | private val repositoryService: RepositoryService, |
| 33 | + private val taskService: TaskService, | |
| 29 | 34 | ) { |
| 30 | 35 | private val log = LoggerFactory.getLogger(WorkflowService::class.java) |
| 31 | 36 | |
| ... | ... | @@ -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 | 207 | companion object { |
| 143 | 208 | /** |
| 144 | 209 | * Reserved process variable name: the UUID-string id of the |
| ... | ... | @@ -178,3 +243,23 @@ data class ProcessDefinitionSummary( |
| 178 | 243 | val deploymentId: String, |
| 179 | 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 | import org.springframework.web.bind.annotation.RequestBody |
| 12 | 12 | import org.springframework.web.bind.annotation.RequestMapping |
| 13 | 13 | import org.springframework.web.bind.annotation.RestController |
| 14 | +import org.springframework.web.bind.annotation.ResponseStatus | |
| 14 | 15 | import org.vibeerp.platform.security.authz.RequirePermission |
| 15 | 16 | import org.vibeerp.platform.workflow.ProcessDefinitionSummary |
| 16 | 17 | import org.vibeerp.platform.workflow.ProcessInstanceSummary |
| 17 | 18 | import org.vibeerp.platform.workflow.StartedProcessInstance |
| 18 | 19 | import org.vibeerp.platform.workflow.TaskHandlerRegistry |
| 20 | +import org.vibeerp.platform.workflow.UserTaskDetail | |
| 21 | +import org.vibeerp.platform.workflow.UserTaskSummary | |
| 19 | 22 | import org.vibeerp.platform.workflow.WorkflowService |
| 20 | 23 | |
| 21 | 24 | /** |
| ... | ... | @@ -26,6 +29,9 @@ import org.vibeerp.platform.workflow.WorkflowService |
| 26 | 29 | * - `GET /api/v1/workflow/process-instances/{id}/variables` — inspect vars |
| 27 | 30 | * - `GET /api/v1/workflow/definitions` — list deployed definitions |
| 28 | 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 | 36 | * The endpoints are permission-gated using the same |
| 31 | 37 | * [org.vibeerp.platform.security.authz.RequirePermission] aspect every PBC |
| ... | ... | @@ -69,6 +75,28 @@ class WorkflowController( |
| 69 | 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 | 100 | @ExceptionHandler(NoSuchElementException::class, FlowableObjectNotFoundException::class) |
| 73 | 101 | fun handleMissing(ex: Exception): ResponseEntity<ErrorResponse> = |
| 74 | 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 | 9 | description: Read active workflow process instances and their variables |
| 10 | 10 | - key: workflow.definition.read |
| 11 | 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 | 17 | menus: |
| 14 | 18 | - path: /workflow/processes |
| ... | ... | @@ -21,3 +25,8 @@ menus: |
| 21 | 25 | icon: file-code |
| 22 | 26 | section: Workflow |
| 23 | 27 | order: 710 |
| 28 | + - path: /workflow/tasks | |
| 29 | + label: Tasks | |
| 30 | + icon: clipboard-check | |
| 31 | + section: Workflow | |
| 32 | + order: 850 | ... | ... |