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 com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
|
||||
import com.all8ai.aioa.workflow.infrastructure.FlowableLeaveWorkflowGateway
|
||||
|
||||
@Service
|
||||
class LeaveRequestService(
|
||||
@@ -33,6 +35,7 @@ class LeaveRequestService(
|
||||
private val routingRepository: ApprovalRoutingRepository,
|
||||
private val workflowGateway: LeaveWorkflowGateway,
|
||||
private val notificationService: NotificationService? = null,
|
||||
private val processBindingRouter: ProcessBindingRouter? = null,
|
||||
) {
|
||||
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||
@@ -86,6 +89,13 @@ class LeaveRequestService(
|
||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||
val existing = getOwn(actor, id)
|
||||
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)
|
||||
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
||||
if (approverId == actor.id) {
|
||||
@@ -121,7 +131,8 @@ class LeaveRequestService(
|
||||
)
|
||||
if (outcome.replayed) return outcome.leaveRequest
|
||||
|
||||
val process = workflowGateway.startLeaveApproval(
|
||||
val process = workflowGateway.startLeaveApprovalWithDefinition(
|
||||
processDefinitionKey,
|
||||
actor.tenantId,
|
||||
outcome.leaveRequest.id,
|
||||
actor.id,
|
||||
|
||||
+47
-59
@@ -1,76 +1,64 @@
|
||||
package com.all8ai.aioa.forms.api
|
||||
|
||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||
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.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@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}")
|
||||
fun getDefinition(@PathVariable formKey: String): FormDefinitionResponse = when (formKey) {
|
||||
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
|
||||
else -> throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
|
||||
fun getPublished(@AuthenticationPrincipal jwt: Jwt, @PathVariable formKey: String): FormDefinitionResponse {
|
||||
val actor = currentUsers!!.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||
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 {
|
||||
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
|
||||
|
||||
val leaveRequestDefinition = FormDefinitionResponse(
|
||||
key = LEAVE_REQUEST_FORM_KEY,
|
||||
version = 1,
|
||||
dataSchema = mapOf(
|
||||
"\$id" to "leave-request-v1",
|
||||
"title" to "请假申请",
|
||||
"type" to "object",
|
||||
"required" to listOf("type", "startsAt", "endsAt", "reason"),
|
||||
"properties" to mapOf(
|
||||
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
|
||||
"startsAt" to mapOf("type" to "string", "format" to "date-time"),
|
||||
"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 可以帮助整理表达,但提交前必须由你确认。",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
LEAVE_REQUEST_FORM_KEY, 1,
|
||||
mapOf("\$id" to "leave-request-v1", "title" to "请假申请", "type" to "object", "required" to listOf("type", "startsAt", "endsAt", "reason"), "properties" to mapOf(
|
||||
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
|
||||
"startsAt" to mapOf("type" to "string", "format" to "date-time"), "endsAt" to mapOf("type" to "string", "format" to "date-time"),
|
||||
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000))),
|
||||
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(
|
||||
val key: String,
|
||||
val version: Int,
|
||||
val dataSchema: Map<String, Any>,
|
||||
val uiSchema: Map<String, Any>,
|
||||
)
|
||||
data class FormDefinitionResponse(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,
|
||||
WORKFLOW_READ_TENANT,
|
||||
OPERATIONS_METRICS_READ_TENANT,
|
||||
PROCESS_CONFIGURATION_MANAGE_TENANT,
|
||||
}
|
||||
|
||||
enum class DataScope { OWN, ASSIGNED, TENANT }
|
||||
@@ -50,6 +51,7 @@ object AuthorizationPolicy {
|
||||
ToolPermission.ORGANIZATION_MANAGE_TENANT,
|
||||
ToolPermission.WORKFLOW_READ_TENANT,
|
||||
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
|
||||
ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT,
|
||||
))
|
||||
}
|
||||
return UserCapabilities(
|
||||
|
||||
@@ -15,6 +15,18 @@ interface LeaveWorkflowGateway {
|
||||
leaveType: String,
|
||||
): 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 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,
|
||||
durationMinutes: Long,
|
||||
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 {
|
||||
val process = runtimeService.createProcessInstanceBuilder()
|
||||
.processDefinitionKey(PROCESS_DEFINITION_KEY)
|
||||
.processDefinitionKey(processDefinitionKey)
|
||||
.businessKey(leaveRequestId.toString())
|
||||
.variables(
|
||||
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
|
||||
info:
|
||||
title: AIOA API
|
||||
version: 0.14.0
|
||||
version: 0.15.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
security:
|
||||
- bearerAuth: []
|
||||
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:
|
||||
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
|
||||
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
|
||||
@@ -660,7 +670,7 @@ components:
|
||||
uniqueItems: true
|
||||
items:
|
||||
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:
|
||||
type: array
|
||||
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] Flutter 管理工具入口与权限驱动展示
|
||||
- [x] 业务与推送运行指标仪表板
|
||||
- [x] 表单版本发布、业务流程绑定与服务端路由选择
|
||||
|
||||
## Definition of Done
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ class AdminRepository {
|
||||
_list('/admin/workflows/definitions'),
|
||||
_list('/admin/workflows/instances'),
|
||||
_list('/admin/audit-events?limit=100'),
|
||||
_list('/admin/process-configuration/forms'),
|
||||
_list('/admin/process-configuration/bindings'),
|
||||
]);
|
||||
return {
|
||||
'metrics': values[0],
|
||||
@@ -25,6 +27,8 @@ class AdminRepository {
|
||||
'definitions': values[4],
|
||||
'instances': values[5],
|
||||
'audits': values[6],
|
||||
'formVersions': values[7],
|
||||
'bindings': values[8],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,31 @@ class _Workflow extends StatelessWidget {
|
||||
Widget build(BuildContext context) => ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
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('流程定义'),
|
||||
...(data['definitions']! as List<Map<String, Object?>>).map(
|
||||
(item) => ListTile(
|
||||
|
||||
Reference in New Issue
Block a user