From a091d388f81d372ad39afd26357e13bc969c4682 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 9 Apr 2026 12:06:02 +0800 Subject: [PATCH] feat(workflow): plug-in-loaded TaskHandler registration + ref-plugin PlateApprovalTaskHandler --- api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt | 21 +++++++++++++++++++++ api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/PluginTaskHandlerRegistrar.kt | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-plugins/build.gradle.kts | 1 + platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt | 11 +++++++++++ platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt | 12 ++++++++++-- platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/workflow/ScopedTaskHandlerRegistrar.kt | 30 ++++++++++++++++++++++++++++++ platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------ platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt | 39 ++++++++++++++++++++++++++++++++++----- reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt | 11 +++++++++++ reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/PlateApprovalTaskHandler.kt | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 305 insertions(+), 43 deletions(-) create mode 100644 api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/PluginTaskHandlerRegistrar.kt create mode 100644 platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/workflow/ScopedTaskHandlerRegistrar.kt create mode 100644 reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/PlateApprovalTaskHandler.kt diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt index 7722e80..e4b2616 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.i18n.LocaleProvider import org.vibeerp.api.v1.i18n.Translator import org.vibeerp.api.v1.persistence.Transaction import org.vibeerp.api.v1.security.PermissionCheck +import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar /** * The bag of host services a plug-in receives at [Plugin.start]. @@ -91,6 +92,26 @@ interface PluginContext { "PluginContext.jdbc is not implemented by this host. " + "Upgrade vibe_erp to v0.6 or later." ) + + /** + * Register workflow [org.vibeerp.api.v1.workflow.TaskHandler] + * implementations with the framework's BPMN engine. Plug-ins that + * ship BPMN-driven logic supply a handler per `` + * through this registrar. + * + * Added in api.v1.0.x; default implementation throws so older + * builds of the host remain binary compatible with newer plug-in + * jars and vice versa (CLAUDE.md guardrail #10 — additive changes + * within a major version ship with a default implementation). + * The first host that wires this is `platform-plugins` v0.7 + * (the P2.1 follow-up chunk that plumbs plug-in handlers through + * the Flowable dispatcher). + */ + val taskHandlers: PluginTaskHandlerRegistrar + get() = throw UnsupportedOperationException( + "PluginContext.taskHandlers is not implemented by this host. " + + "Upgrade vibe_erp to v0.7 or later." + ) } /** diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/PluginTaskHandlerRegistrar.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/PluginTaskHandlerRegistrar.kt new file mode 100644 index 0000000..0b49e2b --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/PluginTaskHandlerRegistrar.kt @@ -0,0 +1,51 @@ +package org.vibeerp.api.v1.workflow + +/** + * The per-plug-in registrar a plug-in uses to register its own + * [TaskHandler] implementations with the framework's workflow engine. + * + * Wired at plug-in start time via + * [org.vibeerp.api.v1.plugin.PluginContext.taskHandlers]. A plug-in + * calls it from inside its `start(context)` lambda: + * + * ``` + * class PrintingShopPlugin(wrapper: PluginWrapper) : Plugin(wrapper) { + * override fun start(context: PluginContext) { + * context.taskHandlers.register(PlateApprovalTaskHandler()) + * context.taskHandlers.register(QuoteToJobCardTaskHandler()) + * } + * } + * ``` + * + * Why a dedicated registrar on the context (matching the existing + * `endpoints` / `jdbc` pattern) instead of a direct handle to the + * framework registry: + * - Ownership tagging is invisible to the plug-in author. The host + * records each call against the plug-in's id so the framework can + * `unregisterAll` at stop time without the plug-in having to track + * the keys it registered. + * - Plug-ins cannot impersonate another plug-in's namespace. The + * registrar is constructed by the host with a fixed plug-in id. + * - It keeps the api.v1 surface clean: the plug-in never sees the + * host-side [TaskHandler] collection, registration semantics, + * threading model, or concrete registry class. + * + * Duplicate-key handling: if a plug-in tries to register a + * [TaskHandler.key] that a core handler or another plug-in already + * owns, [register] throws [IllegalStateException] and the plug-in's + * `start(context)` fails. The plug-in loader strips any partial + * registrations the plug-in made before throwing, so a half-registered + * state cannot linger. + * + * Thread safety: called only from the plug-in lifecycle (boot, + * later hot-reload), never from request-handling threads. + */ +public interface PluginTaskHandlerRegistrar { + + /** + * Register a [TaskHandler] with the framework so BPMN service + * tasks whose `id` attribute equals [TaskHandler.key] are + * dispatched to it. + */ + public fun register(handler: TaskHandler) +} diff --git a/platform/platform-plugins/build.gradle.kts b/platform/platform-plugins/build.gradle.kts index 0fe1683..f1aa23e 100644 --- a/platform/platform-plugins/build.gradle.kts +++ b/platform/platform-plugins/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...) implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider implementation(project(":platform:platform-security")) // for PermissionEvaluator refresh wiring + implementation(project(":platform:platform-workflow")) // for per-plug-in TaskHandler registration implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt index c08ce4f..412569b 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt @@ -11,6 +11,7 @@ import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar import org.vibeerp.api.v1.plugin.PluginJdbc import org.vibeerp.api.v1.plugin.PluginLogger import org.vibeerp.api.v1.security.PermissionCheck +import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar /** * The host's [PluginContext] implementation. @@ -47,6 +48,7 @@ internal class DefaultPluginContext( private val sharedJdbc: PluginJdbc, private val pluginTranslator: Translator, private val sharedLocaleProvider: LocaleProvider, + private val scopedTaskHandlers: PluginTaskHandlerRegistrar, ) : PluginContext { override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger) @@ -96,6 +98,15 @@ internal class DefaultPluginContext( */ override val localeProvider: LocaleProvider = sharedLocaleProvider + /** + * Per-plug-in [PluginTaskHandlerRegistrar] wired in the P2.1 + * follow-up. The scoped registrar tags every registration with + * this plug-in's id so the framework's [TaskHandlerRegistry] can + * drop every handler this plug-in contributed in one call at + * plug-in stop time. + */ + override val taskHandlers: PluginTaskHandlerRegistrar = scopedTaskHandlers + // ─── Not yet implemented ─────────────────────────────────────── override val transaction: Transaction diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt index d5b2cd6..35f8f37 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt @@ -18,6 +18,8 @@ import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar import org.vibeerp.platform.plugins.lint.PluginLinter import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner +import org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar +import org.vibeerp.platform.workflow.TaskHandlerRegistry import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths @@ -65,6 +67,7 @@ class VibeErpPluginManager( private val customFieldRegistry: CustomFieldRegistry, private val permissionEvaluator: PermissionEvaluator, private val localeProvider: LocaleProvider, + private val taskHandlerRegistry: TaskHandlerRegistry, ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) @@ -275,6 +278,7 @@ class VibeErpPluginManager( sharedJdbc = jdbc, pluginTranslator = pluginTranslator, sharedLocaleProvider = localeProvider, + scopedTaskHandlers = ScopedTaskHandlerRegistrar(taskHandlerRegistry, pluginId), ) try { vibeErpPlugin.start(context) @@ -282,9 +286,12 @@ class VibeErpPluginManager( log.info("vibe_erp plug-in '{}' started successfully", pluginId) } catch (ex: Throwable) { log.error("vibe_erp plug-in '{}' failed to start; the plug-in is loaded but inactive", pluginId, ex) - // Strip any partial endpoint registrations the plug-in may - // have managed before throwing. + // Strip any partial registrations the plug-in may have + // managed before throwing — same treatment for endpoints + // and TaskHandlers so a failed start does not leave either + // registry in a half-populated state. endpointRegistry.unregisterAll(pluginId) + taskHandlerRegistry.unregisterAllByOwner(pluginId) } } @@ -298,6 +305,7 @@ class VibeErpPluginManager( log.warn("vibe_erp plug-in '{}' threw during stop; continuing teardown", pluginId, ex) } endpointRegistry.unregisterAll(pluginId) + taskHandlerRegistry.unregisterAllByOwner(pluginId) } started.clear() stopPlugins() diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/workflow/ScopedTaskHandlerRegistrar.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/workflow/ScopedTaskHandlerRegistrar.kt new file mode 100644 index 0000000..23ff2e4 --- /dev/null +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/workflow/ScopedTaskHandlerRegistrar.kt @@ -0,0 +1,30 @@ +package org.vibeerp.platform.plugins.workflow + +import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar +import org.vibeerp.api.v1.workflow.TaskHandler +import org.vibeerp.platform.workflow.TaskHandlerRegistry + +/** + * The per-plug-in implementation of [PluginTaskHandlerRegistrar] that + * plug-ins receive through [org.vibeerp.api.v1.plugin.PluginContext.taskHandlers]. + * + * Holds a reference to the framework's host [TaskHandlerRegistry] and + * a fixed `pluginId`; every [register] call automatically tags the + * registration with the plug-in's id so the host can strip every + * handler the plug-in contributed with a single + * `TaskHandlerRegistry.unregisterAllByOwner(pluginId)` call at plug-in + * stop time (see [org.vibeerp.platform.plugins.VibeErpPluginManager.destroy]). + * + * Plug-ins cannot impersonate another plug-in's namespace because + * they never get to set the `ownerId` argument — it's baked into this + * registrar by the host when it constructs [DefaultPluginContext]. + */ +internal class ScopedTaskHandlerRegistrar( + private val hostRegistry: TaskHandlerRegistry, + private val pluginId: String, +) : PluginTaskHandlerRegistrar { + + override fun register(handler: TaskHandler) { + hostRegistry.register(handler, ownerId = pluginId) + } +} diff --git a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt index b23a203..37371b0 100644 --- a/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt +++ b/platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistry.kt @@ -10,39 +10,44 @@ import java.util.concurrent.ConcurrentHashMap * framework, keyed by [TaskHandler.key]. * * Population lifecycle: - * - At Spring context refresh, every `@Component` / `@Service` bean that - * implements [TaskHandler] is auto-wired into the constructor and - * registered immediately. This covers core framework handlers and - * PBC-contributed ones. - * - At plug-in start time, the plug-in loader is expected to walk the - * plug-in's child context for beans implementing [TaskHandler] and call - * [register] on each. Plug-in integration lands in a later chunk; the - * API exists today so the seam is defined. - * - At plug-in stop time, the plug-in loader calls [unregister] so the - * handler stops being dispatched. + * - At Spring context refresh, every `@Component` / `@Service` bean + * that implements [TaskHandler] is auto-wired into the constructor + * and registered with [OWNER_CORE]. This covers core framework + * handlers and PBC-contributed ones. + * - At plug-in start time, the plug-in loader hands each plug-in a + * scoped registrar; that registrar calls [register] with the + * plug-in id as [ownerId]. The plug-in code itself never sees the + * owner id or this registry directly. + * - At plug-in stop time, the plug-in loader calls + * [unregisterAllByOwner] so every handler the plug-in contributed + * disappears at once. * - * Why a single registry instead of a direct Spring lookup: - * - The dispatcher runs inside Flowable's executor threads, where pulling - * beans out of a `ListableBeanFactory` on every service-task execution - * would be both slow and confusing about classloader ownership when - * plug-ins are involved. - * - Plug-in handlers live in child Spring contexts which are not visible - * to the parent context's default bean list. A deliberate register/ - * unregister API is the correct seam. + * Why a single registry with owner tagging instead of direct Spring + * lookup: + * - The dispatcher runs inside Flowable's executor threads, where + * pulling beans out of a `ListableBeanFactory` on every service-task + * execution would be both slow and confusing about classloader + * ownership when plug-ins are involved. + * - Plug-in handlers live in classloader-isolated child contexts + * which are not visible to the parent Spring context's default bean + * list. A deliberate register/unregister API is the correct seam. + * - Owner tagging lets the plug-in lifecycle unregister everything a + * plug-in contributed in one call at stop time, without the + * plug-in or the loader having to track the keys it registered. * * Thread-safety: the registry is backed by a [ConcurrentHashMap]. The - * dispatcher performs only reads on the hot path; registration happens - * during boot and plug-in lifecycle events. + * dispatcher performs only reads on the hot path; registration + * happens during boot and plug-in lifecycle events. */ @Component class TaskHandlerRegistry( initialHandlers: List = emptyList(), ) { private val log = LoggerFactory.getLogger(TaskHandlerRegistry::class.java) - private val handlers: ConcurrentHashMap = ConcurrentHashMap() + private val handlers: ConcurrentHashMap = ConcurrentHashMap() init { - initialHandlers.forEach(::register) + initialHandlers.forEach { register(it, OWNER_CORE) } log.info( "TaskHandlerRegistry initialised with {} core TaskHandler bean(s): {}", handlers.size, @@ -51,40 +56,68 @@ class TaskHandlerRegistry( } /** - * Register a handler. Throws [IllegalStateException] if another handler - * has already claimed the same key — duplicate keys are a design error - * that must fail at registration time rather than silently overwriting. + * Register a handler, tagging it with an owner id ([OWNER_CORE] or + * a plug-in id). Throws [IllegalStateException] if another handler + * has already claimed the same key — duplicate keys are a design + * error that must fail at registration time rather than silently + * overwriting. */ - fun register(handler: TaskHandler) { + fun register(handler: TaskHandler, ownerId: String = OWNER_CORE) { val key = handler.key() require(key.isNotBlank()) { "TaskHandler.key() must not be blank (offender: ${handler.javaClass.name})" } - val existing = handlers.putIfAbsent(key, handler) + require(ownerId.isNotBlank()) { + "TaskHandlerRegistry.register ownerId must not be blank" + } + val entry = Entry(handler, ownerId) + val existing = handlers.putIfAbsent(key, entry) check(existing == null) { - "duplicate TaskHandler key '$key' — already registered by ${existing?.javaClass?.name}, " + - "attempted to register ${handler.javaClass.name}" + "duplicate TaskHandler key '$key' — already registered by " + + "${existing?.handler?.javaClass?.name} (owner='${existing?.ownerId}'), " + + "attempted to register ${handler.javaClass.name} (owner='$ownerId')" } - log.info("registered TaskHandler '{}' -> {}", key, handler.javaClass.name) + log.info("registered TaskHandler '{}' owner='{}' class='{}'", key, ownerId, handler.javaClass.name) } /** - * Remove a handler by key. Returns true if a handler was removed. Used - * by the plug-in lifecycle at stop time. + * Remove a handler by key. Returns true if a handler was removed. + * Used by tests; the plug-in lifecycle prefers [unregisterAllByOwner]. */ fun unregister(key: String): Boolean { val removed = handlers.remove(key) ?: return false - log.info("unregistered TaskHandler '{}' -> {}", key, removed.javaClass.name) + log.info("unregistered TaskHandler '{}' owner='{}'", key, removed.ownerId) return true } /** + * Remove every handler owned by [ownerId]. Returns the count of + * entries removed. Called by the plug-in lifecycle at stop time. + */ + fun unregisterAllByOwner(ownerId: String): Int { + var removed = 0 + val iterator = handlers.entries.iterator() + while (iterator.hasNext()) { + val (key, entry) = iterator.next() + if (entry.ownerId == ownerId) { + iterator.remove() + removed += 1 + log.info("unregistered TaskHandler '{}' (owner stopped)", key) + } + } + if (removed > 0) { + log.info("TaskHandlerRegistry.unregisterAllByOwner('{}') removed {} handler(s)", ownerId, removed) + } + return removed + } + + /** * Look up a handler by key. The dispatcher calls this on every * service-task execution. Returns null if no handler is registered — - * the dispatcher will then throw a BPMN error that the surrounding - * process can observe. + * the dispatcher will then throw so the surrounding process fails + * loudly instead of silently skipping. */ - fun find(key: String): TaskHandler? = handlers[key] + fun find(key: String): TaskHandler? = handlers[key]?.handler /** * The set of currently known task keys. Exposed for the diagnostic @@ -94,4 +127,15 @@ class TaskHandlerRegistry( /** The number of currently registered handlers. */ fun size(): Int = handlers.size + + /** + * Owner id attached to every core `@Component` TaskHandler bean at + * construction time. Plug-in-contributed handlers carry the + * plug-in id instead. + */ + companion object { + const val OWNER_CORE: String = "core" + } + + private data class Entry(val handler: TaskHandler, val ownerId: String) } diff --git a/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt index bdb8951..9c2abfe 100644 --- a/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt +++ b/platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/TaskHandlerRegistryTest.kt @@ -29,20 +29,49 @@ class TaskHandlerRegistryTest { } @Test - fun `duplicate key fails fast`() { + fun `duplicate key fails fast and includes both owners in the error`() { val registry = TaskHandlerRegistry() - registry.register(FakeHandler("dup.key")) + registry.register(FakeHandler("dup.key"), ownerId = "core") - assertFailure { registry.register(FakeHandler("dup.key")) } + assertFailure { registry.register(FakeHandler("dup.key"), ownerId = "printing-shop") } .isInstanceOf(IllegalStateException::class) .hasMessage( "duplicate TaskHandler key 'dup.key' — already registered by " + - "org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler, " + - "attempted to register org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler", + "org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler (owner='core'), " + + "attempted to register org.vibeerp.platform.workflow.TaskHandlerRegistryTest\$FakeHandler (owner='printing-shop')", ) } @Test + fun `unregisterAllByOwner only removes handlers owned by that id`() { + val registry = TaskHandlerRegistry() + registry.register(FakeHandler("core.a"), ownerId = "core") + registry.register(FakeHandler("core.b"), ownerId = "core") + registry.register(FakeHandler("plugin.a"), ownerId = "printing-shop") + registry.register(FakeHandler("plugin.b"), ownerId = "printing-shop") + + val removed = registry.unregisterAllByOwner("printing-shop") + + assertThat(removed).isEqualTo(2) + assertThat(registry.size()).isEqualTo(2) + assertThat(registry.keys()).isEqualTo(setOf("core.a", "core.b")) + } + + @Test + fun `unregisterAllByOwner on unknown owner returns zero`() { + val registry = TaskHandlerRegistry(listOf(FakeHandler("core.a"))) + assertThat(registry.unregisterAllByOwner("never.seen")).isEqualTo(0) + assertThat(registry.size()).isEqualTo(1) + } + + @Test + fun `register with blank owner is rejected`() { + val registry = TaskHandlerRegistry() + assertFailure { registry.register(FakeHandler("k"), ownerId = " ") } + .isInstanceOf(IllegalArgumentException::class) + } + + @Test fun `blank key rejected`() { val registry = TaskHandlerRegistry() val blankHandler = mockk() diff --git a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt index 2bf86ee..0ee8fb2 100644 --- a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt +++ b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.plugin.HttpMethod import org.vibeerp.api.v1.plugin.PluginContext import org.vibeerp.api.v1.plugin.PluginRequest import org.vibeerp.api.v1.plugin.PluginResponse +import org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler import java.time.Instant import java.util.UUID import org.pf4j.Plugin as Pf4jPlugin @@ -268,6 +269,16 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP } context.logger.info("registered 7 endpoints under /api/v1/plugins/printing-shop/") + + // ─── Workflow task handlers (P2.1 plug-in-loaded registration) ── + // The host's VibeErpPluginManager wires context.taskHandlers to a + // scoped registrar that tags every registration with this plug-in's + // id, so shutdown automatically strips them from the framework's + // TaskHandlerRegistry via unregisterAllByOwner("printing-shop"). + context.taskHandlers.register(PlateApprovalTaskHandler()) + context.logger.info( + "registered 1 TaskHandler: ${PlateApprovalTaskHandler.KEY}", + ) } /** diff --git a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/PlateApprovalTaskHandler.kt b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/PlateApprovalTaskHandler.kt new file mode 100644 index 0000000..1e0fe33 --- /dev/null +++ b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/PlateApprovalTaskHandler.kt @@ -0,0 +1,56 @@ +package org.vibeerp.reference.printingshop.workflow + +import org.vibeerp.api.v1.security.Principal +import org.vibeerp.api.v1.workflow.TaskContext +import org.vibeerp.api.v1.workflow.TaskHandler +import org.vibeerp.api.v1.workflow.WorkflowTask +import java.time.Instant + +/** + * Reference plug-in-contributed TaskHandler — the executable + * acceptance test for the P2.1 plug-in-loaded TaskHandler registration + * seam. + * + * The handler is deliberately minimal: it takes a `plateId` variable + * off the BPMN process, "approves" it by writing `plateApproved=true` + * + the approving principal + timestamp, and exits. Real printing-shop + * plate approval would also UPDATE the `plugin_printingshop__plate` + * table via `context.jdbc` — but the plug-in loader doesn't give + * TaskHandlers access to the full [org.vibeerp.api.v1.plugin.PluginContext] + * yet, so v0.7 of this handler is a pure functional bridge. Giving + * handlers a context (or a narrowed projection thereof) is the + * obvious next chunk once REF.1 lands a real BPMN process that needs + * plug-in state mutation. + * + * Key naming follows the convention documented on + * [org.vibeerp.api.v1.workflow.TaskHandler.key]: + * `..`. The plug-in id here is + * `printing_shop` (underscored for BPMN xsd:ID compatibility; the + * actual PF4J plug-in id is `printing-shop`, but BPMN service-task + * ids don't allow hyphens inside nested parts so we use the + * underscored form for workflow keys throughout the framework). + */ +class PlateApprovalTaskHandler : TaskHandler { + + override fun key(): String = KEY + + override fun execute(task: WorkflowTask, ctx: TaskContext) { + val plateId = task.variables["plateId"] as? String + ?: error("PlateApprovalTaskHandler: BPMN process missing 'plateId' variable") + + val approverLabel = when (val p = ctx.principal()) { + is Principal.User -> "user:${p.username}" + is Principal.System -> "system:${p.name}" + is Principal.PluginPrincipal -> "plugin:${p.pluginId}" + } + + ctx.set("plateApproved", true) + ctx.set("plateId", plateId) + ctx.set("approvedBy", approverLabel) + ctx.set("approvedAt", Instant.now().toString()) + } + + companion object { + const val KEY: String = "printing_shop.plate.approve" + } +} -- libgit2 0.22.2