diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt new file mode 100644 index 0000000..a537c4f --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt @@ -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> { 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> { 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 { + 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 { + 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,ui:Map){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,val uiSchema:Map) +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) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt index c2d577f..94476d2 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt @@ -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, diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/forms/api/FormDefinitionController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/forms/api/FormDefinitionController.kt index 4d523c1..7590a11 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/forms/api/FormDefinitionController.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/forms/api/FormDefinitionController.kt @@ -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, + objectMapper.readValue(it.get("ui_schema")!!.toString(), Map::class.java) as Map, + ) + } + + 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, - val uiSchema: Map, -) +data class FormDefinitionResponse(val key: String, val version: Int, val dataSchema: Map, val uiSchema: Map) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt index 10451da..96970c6 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt @@ -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( diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt index a2ac991..451d220 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt @@ -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 fun resolveTask(taskId: String): WorkflowTask? diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/ProcessBinding.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/ProcessBinding.kt new file mode 100644 index 0000000..c302198 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/ProcessBinding.kt @@ -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? +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt index 06400d2..5ef8d73 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt @@ -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( diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/JooqProcessBindingRouter.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/JooqProcessBindingRouter.kt new file mode 100644 index 0000000..eec92cd --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/JooqProcessBindingRouter.kt @@ -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)!!, + ) } +} diff --git a/backend/boot/src/main/resources/db/migration/V13__create_form_versions_and_process_bindings.sql b/backend/boot/src/main/resources/db/migration/V13__create_form_versions_and_process_bindings.sql new file mode 100644 index 0000000..4b92bc7 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V13__create_form_versions_and_process_bindings.sql @@ -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 +); diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index 7508df6..9bb6269 100644 --- a/contracts/openapi/aioa-v1.yaml +++ b/contracts/openapi/aioa-v1.yaml @@ -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 diff --git a/docs/engineering/process-form-binding.md b/docs/engineering/process-form-binding.md new file mode 100644 index 0000000..fa5cbf0 --- /dev/null +++ b/docs/engineering/process-form-binding.md @@ -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。 +- 创建、发布、启停均写入审计。 diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index 2cdcdfb..84c3187 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -55,6 +55,7 @@ - [x] 流程定义、版本和运行实例只读管理 - [x] Flutter 管理工具入口与权限驱动展示 - [x] 业务与推送运行指标仪表板 +- [x] 表单版本发布、业务流程绑定与服务端路由选择 ## Definition of Done diff --git a/mobile/lib/features/admin/data/admin_repository.dart b/mobile/lib/features/admin/data/admin_repository.dart index 2f5a5d9..8a7fa6c 100644 --- a/mobile/lib/features/admin/data/admin_repository.dart +++ b/mobile/lib/features/admin/data/admin_repository.dart @@ -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], }; } diff --git a/mobile/lib/features/admin/presentation/admin_page.dart b/mobile/lib/features/admin/presentation/admin_page.dart index 6a29f09..6bd743b 100644 --- a/mobile/lib/features/admin/presentation/admin_page.dart +++ b/mobile/lib/features/admin/presentation/admin_page.dart @@ -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( + (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( + (item) => ListTile( + title: Text('${item['form_key']} v${item['version']}'), + subtitle: Text('${item['status']}'), + ), + ), + const Divider(), const Text('流程定义'), ...(data['definitions']! as List>).map( (item) => ListTile(