feat: add versioned form and process routing
This commit is contained in:
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.all8ai.aioa.admin.configuration
|
||||||
|
|
||||||
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.shared.id.UuidV7
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.flowable.engine.RepositoryService
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController @RequestMapping("/api/v1/admin/process-configuration")
|
||||||
|
class ProcessConfigurationController(
|
||||||
|
private val users:CurrentUserService,private val dsl:DSLContext,private val mapper:ObjectMapper,
|
||||||
|
private val flowable:RepositoryService,private val audit:AuditService,
|
||||||
|
) {
|
||||||
|
private fun actor(jwt:Jwt)=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT) }
|
||||||
|
@GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT form_key,version,status,created_at,published_at FROM form.definition WHERE tenant_id=? ORDER BY form_key,version DESC",a.tenantId).map{it.intoMap()} }
|
||||||
|
@GetMapping("/bindings") fun bindings(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status FROM workflow.process_binding WHERE tenant_id=? ORDER BY priority DESC",a.tenantId).map{it.intoMap()} }
|
||||||
|
@PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map<String,Any?> {
|
||||||
|
val a=actor(jwt);validateSchema(body.dataSchema,body.uiSchema);val version=(dsl.fetchOne("SELECT COALESCE(MAX(version),0)+1 v FROM form.definition WHERE tenant_id=? AND form_key=?",a.tenantId,body.formKey)?.get("v",Int::class.java)?:1)
|
||||||
|
dsl.execute("INSERT INTO form.definition(tenant_id,form_key,version,status,data_schema,ui_schema) VALUES(?,?,?,'DRAFT',CAST(? AS JSONB),CAST(? AS JSONB))",a.tenantId,body.formKey,version,mapper.writeValueAsString(body.dataSchema),mapper.writeValueAsString(body.uiSchema))
|
||||||
|
audit.recordSuccess(a,"FORM_VERSION_CREATED","FORM_DEFINITION","${body.formKey}:$version",null,mapOf("version" to version));return mapOf("formKey" to body.formKey,"version" to version,"status" to "DRAFT")
|
||||||
|
}
|
||||||
|
@PostMapping("/forms/{key}/{version}/publish") @Transactional fun publish(@AuthenticationPrincipal jwt:Jwt,@PathVariable key:String,@PathVariable version:Int) {
|
||||||
|
val a=actor(jwt);dsl.execute("UPDATE form.definition SET status='RETIRED' WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",a.tenantId,key)
|
||||||
|
if(dsl.execute("UPDATE form.definition SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND form_key=? AND version=? AND status='DRAFT'",a.tenantId,key,version)!=1) throw ApiException(HttpStatus.CONFLICT,"FORM_VERSION_NOT_DRAFT","表单版本不存在或不可发布")
|
||||||
|
audit.recordSuccess(a,"FORM_VERSION_PUBLISHED","FORM_DEFINITION","$key:$version",null,mapOf("version" to version))
|
||||||
|
}
|
||||||
|
@PostMapping("/bindings") @Transactional fun createBinding(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:BindingCommand):Map<String,Any?> {
|
||||||
|
val a=actor(jwt);if(flowable.createProcessDefinitionQuery().processDefinitionKey(body.processDefinitionKey).latestVersion().singleResult()==null) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_DEFINITION_NOT_FOUND","流程定义不存在")
|
||||||
|
val id=UuidV7.generate();dsl.execute("INSERT INTO workflow.process_binding(id,tenant_id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE')",id,a.tenantId,body.businessType,body.formKey,body.formVersion,body.processDefinitionKey,body.leaveType,body.minDurationMinutes,body.maxDurationMinutes,body.priority)
|
||||||
|
audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE")
|
||||||
|
}
|
||||||
|
@PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) }
|
||||||
|
private fun validateSchema(data:Map<String,Any>,ui:Map<String,Any>){if(data["type"]!="object"||data["properties"] !is Map<*,*>||ui["sections"] !is List<*>) throw ApiException(HttpStatus.BAD_REQUEST,"FORM_SCHEMA_INVALID","表单 Schema 结构无效")}
|
||||||
|
}
|
||||||
|
data class FormVersionCommand(val formKey:String,val dataSchema:Map<String,Any>,val uiSchema:Map<String,Any>)
|
||||||
|
data class BindingCommand(val businessType:String,val formKey:String,val formVersion:Int,val processDefinitionKey:String,val leaveType:String?=null,val minDurationMinutes:Long?=null,val maxDurationMinutes:Long?=null,val priority:Int=100)
|
||||||
|
data class BindingStatusCommand(val status:String)
|
||||||
+12
-1
@@ -25,6 +25,8 @@ import java.time.Duration
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import com.all8ai.aioa.shared.security.ToolPermission
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
import com.all8ai.aioa.shared.security.requirePermission
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
|
||||||
|
import com.all8ai.aioa.workflow.infrastructure.FlowableLeaveWorkflowGateway
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class LeaveRequestService(
|
class LeaveRequestService(
|
||||||
@@ -33,6 +35,7 @@ class LeaveRequestService(
|
|||||||
private val routingRepository: ApprovalRoutingRepository,
|
private val routingRepository: ApprovalRoutingRepository,
|
||||||
private val workflowGateway: LeaveWorkflowGateway,
|
private val workflowGateway: LeaveWorkflowGateway,
|
||||||
private val notificationService: NotificationService? = null,
|
private val notificationService: NotificationService? = null,
|
||||||
|
private val processBindingRouter: ProcessBindingRouter? = null,
|
||||||
) {
|
) {
|
||||||
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
||||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||||
@@ -86,6 +89,13 @@ class LeaveRequestService(
|
|||||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||||
val existing = getOwn(actor, id)
|
val existing = getOwn(actor, id)
|
||||||
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
|
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
|
||||||
|
val processDefinitionKey = processBindingRouter?.select(
|
||||||
|
actor.tenantId, "LEAVE_REQUEST", existing.type.name, durationMinutes,
|
||||||
|
)?.processDefinitionKey ?: if (processBindingRouter == null) {
|
||||||
|
FlowableLeaveWorkflowGateway.PROCESS_DEFINITION_KEY
|
||||||
|
} else {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "PROCESS_BINDING_NOT_FOUND", "未找到适用的已启用审批流程")
|
||||||
|
}
|
||||||
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
|
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
|
||||||
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
||||||
if (approverId == actor.id) {
|
if (approverId == actor.id) {
|
||||||
@@ -121,7 +131,8 @@ class LeaveRequestService(
|
|||||||
)
|
)
|
||||||
if (outcome.replayed) return outcome.leaveRequest
|
if (outcome.replayed) return outcome.leaveRequest
|
||||||
|
|
||||||
val process = workflowGateway.startLeaveApproval(
|
val process = workflowGateway.startLeaveApprovalWithDefinition(
|
||||||
|
processDefinitionKey,
|
||||||
actor.tenantId,
|
actor.tenantId,
|
||||||
outcome.leaveRequest.id,
|
outcome.leaveRequest.id,
|
||||||
actor.id,
|
actor.id,
|
||||||
|
|||||||
+47
-59
@@ -1,76 +1,64 @@
|
|||||||
package com.all8ai.aioa.forms.api
|
package com.all8ai.aioa.forms.api
|
||||||
|
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
import com.all8ai.aioa.shared.web.ApiException
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.jooq.DSLContext
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/form-definitions")
|
@RequestMapping("/api/v1/form-definitions")
|
||||||
class FormDefinitionController {
|
class FormDefinitionController(
|
||||||
|
private val currentUsers: CurrentUserService? = null,
|
||||||
|
private val dsl: DSLContext? = null,
|
||||||
|
private val objectMapper: ObjectMapper? = null,
|
||||||
|
) {
|
||||||
@GetMapping("/{formKey}")
|
@GetMapping("/{formKey}")
|
||||||
fun getDefinition(@PathVariable formKey: String): FormDefinitionResponse = when (formKey) {
|
fun getPublished(@AuthenticationPrincipal jwt: Jwt, @PathVariable formKey: String): FormDefinitionResponse {
|
||||||
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
|
val actor = currentUsers!!.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||||
else -> throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
|
return find(actor.tenantId, formKey) ?: notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保留纯单元测试与离线内置定义入口;生产 HTTP 始终读取已发布数据库版本。
|
||||||
|
fun getDefinition(formKey: String): FormDefinitionResponse = when (formKey) {
|
||||||
|
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
|
||||||
|
else -> notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun find(tenantId: UUID, key: String): FormDefinitionResponse? = dsl!!.fetchOne(
|
||||||
|
"SELECT * FROM form.definition WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",
|
||||||
|
tenantId, key,
|
||||||
|
)?.let {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
FormDefinitionResponse(
|
||||||
|
it.get("form_key", String::class.java)!!,
|
||||||
|
it.get("version", Int::class.java)!!,
|
||||||
|
objectMapper!!.readValue(it.get("data_schema")!!.toString(), Map::class.java) as Map<String, Any>,
|
||||||
|
objectMapper.readValue(it.get("ui_schema")!!.toString(), Map::class.java) as Map<String, Any>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notFound(): Nothing = throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
|
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
|
||||||
|
|
||||||
val leaveRequestDefinition = FormDefinitionResponse(
|
val leaveRequestDefinition = FormDefinitionResponse(
|
||||||
key = LEAVE_REQUEST_FORM_KEY,
|
LEAVE_REQUEST_FORM_KEY, 1,
|
||||||
version = 1,
|
mapOf("\$id" to "leave-request-v1", "title" to "请假申请", "type" to "object", "required" to listOf("type", "startsAt", "endsAt", "reason"), "properties" to mapOf(
|
||||||
dataSchema = mapOf(
|
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
|
||||||
"\$id" to "leave-request-v1",
|
"startsAt" to mapOf("type" to "string", "format" to "date-time"), "endsAt" to mapOf("type" to "string", "format" to "date-time"),
|
||||||
"title" to "请假申请",
|
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000))),
|
||||||
"type" to "object",
|
mapOf("description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。", "sections" to listOf(
|
||||||
"required" to listOf("type", "startsAt", "endsAt", "reason"),
|
mapOf("title" to "请假信息", "controls" to listOf(
|
||||||
"properties" to mapOf(
|
mapOf("field" to "type", "label" to "请假类型", "control" to "select", "optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假")),
|
||||||
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
|
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"), mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"))),
|
||||||
"startsAt" to mapOf("type" to "string", "format" to "date-time"),
|
mapOf("title" to "补充说明", "controls" to listOf(mapOf("field" to "reason", "label" to "请假原因", "control" to "textArea", "placeholder" to "请简要说明请假原因", "helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。"))))),
|
||||||
"endsAt" to mapOf("type" to "string", "format" to "date-time"),
|
|
||||||
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
uiSchema = mapOf(
|
|
||||||
"description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。",
|
|
||||||
"sections" to listOf(
|
|
||||||
mapOf(
|
|
||||||
"title" to "请假信息",
|
|
||||||
"controls" to listOf(
|
|
||||||
mapOf(
|
|
||||||
"field" to "type",
|
|
||||||
"label" to "请假类型",
|
|
||||||
"control" to "select",
|
|
||||||
"optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假"),
|
|
||||||
),
|
|
||||||
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"),
|
|
||||||
mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
mapOf(
|
|
||||||
"title" to "补充说明",
|
|
||||||
"controls" to listOf(
|
|
||||||
mapOf(
|
|
||||||
"field" to "reason",
|
|
||||||
"label" to "请假原因",
|
|
||||||
"control" to "textArea",
|
|
||||||
"placeholder" to "请简要说明请假原因",
|
|
||||||
"helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FormDefinitionResponse(
|
data class FormDefinitionResponse(val key: String, val version: Int, val dataSchema: Map<String, Any>, val uiSchema: Map<String, Any>)
|
||||||
val key: String,
|
|
||||||
val version: Int,
|
|
||||||
val dataSchema: Map<String, Any>,
|
|
||||||
val uiSchema: Map<String, Any>,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum class ToolPermission {
|
|||||||
ORGANIZATION_MANAGE_TENANT,
|
ORGANIZATION_MANAGE_TENANT,
|
||||||
WORKFLOW_READ_TENANT,
|
WORKFLOW_READ_TENANT,
|
||||||
OPERATIONS_METRICS_READ_TENANT,
|
OPERATIONS_METRICS_READ_TENANT,
|
||||||
|
PROCESS_CONFIGURATION_MANAGE_TENANT,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class DataScope { OWN, ASSIGNED, TENANT }
|
enum class DataScope { OWN, ASSIGNED, TENANT }
|
||||||
@@ -50,6 +51,7 @@ object AuthorizationPolicy {
|
|||||||
ToolPermission.ORGANIZATION_MANAGE_TENANT,
|
ToolPermission.ORGANIZATION_MANAGE_TENANT,
|
||||||
ToolPermission.WORKFLOW_READ_TENANT,
|
ToolPermission.WORKFLOW_READ_TENANT,
|
||||||
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
|
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
|
||||||
|
ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return UserCapabilities(
|
return UserCapabilities(
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ interface LeaveWorkflowGateway {
|
|||||||
leaveType: String,
|
leaveType: String,
|
||||||
): StartedProcess
|
): StartedProcess
|
||||||
|
|
||||||
|
fun startLeaveApprovalWithDefinition(
|
||||||
|
processDefinitionKey: String,
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
applicantId: UUID,
|
||||||
|
approverId: UUID,
|
||||||
|
oaAdministratorId: UUID,
|
||||||
|
hrReviewerId: UUID,
|
||||||
|
durationMinutes: Long,
|
||||||
|
leaveType: String,
|
||||||
|
): StartedProcess = startLeaveApproval(tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
|
||||||
|
|
||||||
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
||||||
|
|
||||||
fun resolveTask(taskId: String): WorkflowTask?
|
fun resolveTask(taskId: String): WorkflowTask?
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.all8ai.aioa.workflow.domain
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class ProcessBinding(
|
||||||
|
val id: UUID,
|
||||||
|
val businessType: String,
|
||||||
|
val formKey: String,
|
||||||
|
val formVersion: Int,
|
||||||
|
val processDefinitionKey: String,
|
||||||
|
val leaveType: String?,
|
||||||
|
val minDurationMinutes: Long?,
|
||||||
|
val maxDurationMinutes: Long?,
|
||||||
|
val priority: Int,
|
||||||
|
val status: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun interface ProcessBindingRouter {
|
||||||
|
fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding?
|
||||||
|
}
|
||||||
+13
-1
@@ -27,9 +27,21 @@ class FlowableLeaveWorkflowGateway(
|
|||||||
hrReviewerId: UUID,
|
hrReviewerId: UUID,
|
||||||
durationMinutes: Long,
|
durationMinutes: Long,
|
||||||
leaveType: String,
|
leaveType: String,
|
||||||
|
): StartedProcess = startLeaveApprovalWithDefinition(PROCESS_DEFINITION_KEY, tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
|
||||||
|
|
||||||
|
override fun startLeaveApprovalWithDefinition(
|
||||||
|
processDefinitionKey: String,
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
applicantId: UUID,
|
||||||
|
approverId: UUID,
|
||||||
|
oaAdministratorId: UUID,
|
||||||
|
hrReviewerId: UUID,
|
||||||
|
durationMinutes: Long,
|
||||||
|
leaveType: String,
|
||||||
): StartedProcess {
|
): StartedProcess {
|
||||||
val process = runtimeService.createProcessInstanceBuilder()
|
val process = runtimeService.createProcessInstanceBuilder()
|
||||||
.processDefinitionKey(PROCESS_DEFINITION_KEY)
|
.processDefinitionKey(processDefinitionKey)
|
||||||
.businessKey(leaveRequestId.toString())
|
.businessKey(leaveRequestId.toString())
|
||||||
.variables(
|
.variables(
|
||||||
mapOf(
|
mapOf(
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.all8ai.aioa.workflow.infrastructure
|
||||||
|
|
||||||
|
import com.all8ai.aioa.workflow.domain.ProcessBinding
|
||||||
|
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class JooqProcessBindingRouter(private val dsl: DSLContext) : ProcessBindingRouter {
|
||||||
|
override fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding? =
|
||||||
|
dsl.fetchOne(
|
||||||
|
"""
|
||||||
|
SELECT * FROM workflow.process_binding
|
||||||
|
WHERE tenant_id=? AND business_type=? AND status='ACTIVE'
|
||||||
|
AND (leave_type IS NULL OR leave_type=?)
|
||||||
|
AND (min_duration_minutes IS NULL OR min_duration_minutes<=?)
|
||||||
|
AND (max_duration_minutes IS NULL OR max_duration_minutes>=?)
|
||||||
|
ORDER BY priority DESC,
|
||||||
|
(CASE WHEN leave_type IS NULL THEN 0 ELSE 1 END) DESC,
|
||||||
|
(CASE WHEN min_duration_minutes IS NULL AND max_duration_minutes IS NULL THEN 0 ELSE 1 END) DESC
|
||||||
|
LIMIT 1
|
||||||
|
""".trimIndent(), tenantId, businessType, leaveType, durationMinutes, durationMinutes,
|
||||||
|
)?.let { ProcessBinding(
|
||||||
|
it.get("id", UUID::class.java)!!, it.get("business_type", String::class.java)!!,
|
||||||
|
it.get("form_key", String::class.java)!!, it.get("form_version", Int::class.java)!!,
|
||||||
|
it.get("process_definition_key", String::class.java)!!, it.get("leave_type", String::class.java),
|
||||||
|
it.get("min_duration_minutes", Long::class.java), it.get("max_duration_minutes", Long::class.java),
|
||||||
|
it.get("priority", Int::class.java)!!, it.get("status", String::class.java)!!,
|
||||||
|
) }
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE TABLE form.definition (
|
||||||
|
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
|
||||||
|
form_key VARCHAR(100) NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
data_schema JSONB NOT NULL,
|
||||||
|
ui_schema JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (tenant_id, form_key, version),
|
||||||
|
CONSTRAINT ck_form_definition_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'RETIRED'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_form_definition_published
|
||||||
|
ON form.definition (tenant_id, form_key)
|
||||||
|
WHERE status = 'PUBLISHED';
|
||||||
|
|
||||||
|
CREATE TABLE workflow.process_binding (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
|
||||||
|
business_type VARCHAR(100) NOT NULL,
|
||||||
|
form_key VARCHAR(100) NOT NULL,
|
||||||
|
form_version INTEGER NOT NULL,
|
||||||
|
process_definition_key VARCHAR(100) NOT NULL,
|
||||||
|
leave_type VARCHAR(32),
|
||||||
|
min_duration_minutes BIGINT,
|
||||||
|
max_duration_minutes BIGINT,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_process_binding_form FOREIGN KEY (tenant_id, form_key, form_version)
|
||||||
|
REFERENCES form.definition(tenant_id, form_key, version),
|
||||||
|
CONSTRAINT ck_process_binding_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
|
||||||
|
CONSTRAINT ck_process_binding_duration CHECK (
|
||||||
|
(min_duration_minutes IS NULL OR min_duration_minutes >= 0) AND
|
||||||
|
(max_duration_minutes IS NULL OR max_duration_minutes >= min_duration_minutes)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_process_binding_route
|
||||||
|
ON workflow.process_binding (tenant_id, business_type, status, priority DESC);
|
||||||
|
|
||||||
|
INSERT INTO form.definition (tenant_id, form_key, version, status, data_schema, ui_schema, published_at)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-7000-8000-000000000001', 'leave-request', 1, 'PUBLISHED',
|
||||||
|
'{"$id":"leave-request-v1","title":"请假申请","type":"object","required":["type","startsAt","endsAt","reason"],"properties":{"type":{"type":"string","enum":["PERSONAL","SICK","ANNUAL"]},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"reason":{"type":"string","minLength":1,"maxLength":2000}}}'::jsonb,
|
||||||
|
'{"description":"表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。","sections":[{"title":"请假信息","controls":[{"field":"type","label":"请假类型","control":"select","optionLabels":{"PERSONAL":"事假","SICK":"病假","ANNUAL":"年假"}},{"field":"startsAt","label":"开始时间","control":"dateTime"},{"field":"endsAt","label":"结束时间","control":"dateTime"}]},{"title":"补充说明","controls":[{"field":"reason","label":"请假原因","control":"textArea","placeholder":"请简要说明请假原因","helperText":"AI 可以帮助整理表达,但提交前必须由你确认。"}]}]}'::jsonb,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO workflow.process_binding (
|
||||||
|
id, tenant_id, business_type, form_key, form_version, process_definition_key, priority
|
||||||
|
) VALUES (
|
||||||
|
'70000000-0000-7000-8000-000000000001',
|
||||||
|
'00000000-0000-7000-8000-000000000001',
|
||||||
|
'LEAVE_REQUEST', 'leave-request', 1, 'leaveApproval', 100
|
||||||
|
);
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: AIOA API
|
title: AIOA API
|
||||||
version: 0.14.0
|
version: 0.15.0
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
|
/admin/process-configuration/forms:
|
||||||
|
get: { operationId: listFormVersions, summary: 查询表单版本与发布状态, responses: { "200": { description: 表单版本列表 } } }
|
||||||
|
post: { operationId: createFormVersion, summary: 创建安全校验后的表单草稿版本, responses: { "200": { description: 已创建表单草稿版本 } } }
|
||||||
|
/admin/process-configuration/forms/{key}/{version}/publish:
|
||||||
|
post: { operationId: publishFormVersion, summary: 发布表单版本并退役旧发布版本, parameters: [{ name: key, in: path, required: true, schema: { type: string } }, { name: version, in: path, required: true, schema: { type: integer } }], responses: { "200": { description: 已发布 } } }
|
||||||
|
/admin/process-configuration/bindings:
|
||||||
|
get: { operationId: listProcessBindings, summary: 查询业务表单与流程绑定, responses: { "200": { description: 流程绑定列表 } } }
|
||||||
|
post: { operationId: createProcessBinding, summary: 创建业务流程路由绑定, responses: { "200": { description: 已创建并启用绑定 } } }
|
||||||
|
/admin/process-configuration/bindings/{id}/status:
|
||||||
|
put: { operationId: updateProcessBindingStatus, summary: 启用或停用流程绑定, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 状态已更新 } } }
|
||||||
/admin/organization/departments:
|
/admin/organization/departments:
|
||||||
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
|
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
|
||||||
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
|
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
|
||||||
@@ -660,7 +670,7 @@ components:
|
|||||||
uniqueItems: true
|
uniqueItems: true
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED, ORGANIZATION_MANAGE_TENANT, WORKFLOW_READ_TENANT, OPERATIONS_METRICS_READ_TENANT]
|
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED, ORGANIZATION_MANAGE_TENANT, WORKFLOW_READ_TENANT, OPERATIONS_METRICS_READ_TENANT, PROCESS_CONFIGURATION_MANAGE_TENANT]
|
||||||
dataScopes:
|
dataScopes:
|
||||||
type: array
|
type: array
|
||||||
uniqueItems: true
|
uniqueItems: true
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 表单版本与流程选择
|
||||||
|
|
||||||
|
## 发布模型
|
||||||
|
|
||||||
|
表单定义以 `tenant + formKey + version` 唯一标识,状态为 `DRAFT`、`PUBLISHED` 或 `RETIRED`。同一租户同一表单只能有一个已发布版本。
|
||||||
|
|
||||||
|
流程绑定将以下条件映射到 Flowable Process Definition Key:
|
||||||
|
|
||||||
|
- 业务类型
|
||||||
|
- 表单 Key 和版本
|
||||||
|
- 可选业务枚举,例如请假类型
|
||||||
|
- 可选最小时长和最大时长
|
||||||
|
- 优先级
|
||||||
|
- ACTIVE / INACTIVE 状态
|
||||||
|
|
||||||
|
## 运行时选择
|
||||||
|
|
||||||
|
Flutter 只能提交业务表单数据,不能提交流程定义 ID。Kotlin 后端按当前租户和权威业务数据查询绑定:
|
||||||
|
|
||||||
|
1. 只选择 ACTIVE 绑定。
|
||||||
|
2. 业务类型必须精确匹配。
|
||||||
|
3. 枚举和时长条件必须满足。
|
||||||
|
4. 优先级高的规则优先。
|
||||||
|
5. 精确枚举或范围规则优先于通配规则。
|
||||||
|
6. 无匹配绑定时拒绝提交,不启动默认或任意流程。
|
||||||
|
|
||||||
|
流程启动后保存实际 `processDefinitionId`,运行中实例不会因之后修改绑定而切换版本。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 只有 OA 管理员拥有 `PROCESS_CONFIGURATION_MANAGE_TENANT`。
|
||||||
|
- 新表单版本先进入草稿,发布前执行 Schema 结构校验。
|
||||||
|
- 绑定只能引用数据库中存在的表单版本和 Flowable 已部署流程 Key。
|
||||||
|
- 创建、发布、启停均写入审计。
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
- [x] 流程定义、版本和运行实例只读管理
|
- [x] 流程定义、版本和运行实例只读管理
|
||||||
- [x] Flutter 管理工具入口与权限驱动展示
|
- [x] Flutter 管理工具入口与权限驱动展示
|
||||||
- [x] 业务与推送运行指标仪表板
|
- [x] 业务与推送运行指标仪表板
|
||||||
|
- [x] 表单版本发布、业务流程绑定与服务端路由选择
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class AdminRepository {
|
|||||||
_list('/admin/workflows/definitions'),
|
_list('/admin/workflows/definitions'),
|
||||||
_list('/admin/workflows/instances'),
|
_list('/admin/workflows/instances'),
|
||||||
_list('/admin/audit-events?limit=100'),
|
_list('/admin/audit-events?limit=100'),
|
||||||
|
_list('/admin/process-configuration/forms'),
|
||||||
|
_list('/admin/process-configuration/bindings'),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
'metrics': values[0],
|
'metrics': values[0],
|
||||||
@@ -25,6 +27,8 @@ class AdminRepository {
|
|||||||
'definitions': values[4],
|
'definitions': values[4],
|
||||||
'instances': values[5],
|
'instances': values[5],
|
||||||
'audits': values[6],
|
'audits': values[6],
|
||||||
|
'formVersions': values[7],
|
||||||
|
'bindings': values[8],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,31 @@ class _Workflow extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) => ListView(
|
Widget build(BuildContext context) => ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
|
const Text('业务流程绑定'),
|
||||||
|
...(data['bindings']! as List<Map<String, Object?>>).map(
|
||||||
|
(item) => Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
item['status'] == 'ACTIVE' ? Icons.link : Icons.link_off,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'${item['business_type']} → ${item['process_definition_key']}',
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'表单 ${item['form_key']} v${item['form_version']} · 优先级 ${item['priority']}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
const Text('表单版本'),
|
||||||
|
...(data['formVersions']! as List<Map<String, Object?>>).map(
|
||||||
|
(item) => ListTile(
|
||||||
|
title: Text('${item['form_key']} v${item['version']}'),
|
||||||
|
subtitle: Text('${item['status']}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
const Text('流程定义'),
|
const Text('流程定义'),
|
||||||
...(data['definitions']! as List<Map<String, Object?>>).map(
|
...(data['definitions']! as List<Map<String, Object?>>).map(
|
||||||
(item) => ListTile(
|
(item) => ListTile(
|
||||||
|
|||||||
Reference in New Issue
Block a user