feat: integrate Flowable manager approval workflow
This commit is contained in:
@@ -27,6 +27,9 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
|||||||
- 请假草稿创建、修改、本人查询和列表
|
- 请假草稿创建、修改、本人查询和列表
|
||||||
- UUIDv7、写入幂等、租户隔离和乐观锁冲突处理
|
- UUIDv7、写入幂等、租户隔离和乐观锁冲突处理
|
||||||
- 请假提交、撤回、状态时间线与事务内审计
|
- 请假提交、撤回、状态时间线与事务内审计
|
||||||
|
- Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回
|
||||||
|
|
||||||
|
Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。
|
||||||
|
|
||||||
## 开发身份
|
## 开发身份
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ dependencies {
|
|||||||
implementation("org.flywaydb:flyway-core")
|
implementation("org.flywaydb:flyway-core")
|
||||||
implementation("org.flywaydb:flyway-database-postgresql")
|
implementation("org.flywaydb:flyway-database-postgresql")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
|
implementation("org.flowable:flowable-spring-boot-starter-process:7.2.0")
|
||||||
runtimeOnly("org.postgresql:postgresql")
|
runtimeOnly("org.postgresql:postgresql")
|
||||||
|
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.all8ai.aioa.approval.api
|
||||||
|
|
||||||
|
import com.all8ai.aioa.approval.application.ApprovalTask
|
||||||
|
import com.all8ai.aioa.approval.application.ApprovalTaskService
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import jakarta.validation.constraints.PositiveOrZero
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/approval-tasks")
|
||||||
|
class ApprovalTaskController(
|
||||||
|
private val currentUserService: CurrentUserService,
|
||||||
|
private val approvalTaskService: ApprovalTaskService,
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun listAssigned(@AuthenticationPrincipal jwt: Jwt): List<ApprovalTaskResponse> = approvalTaskService
|
||||||
|
.listAssigned(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
|
||||||
|
.map(ApprovalTask::toResponse)
|
||||||
|
|
||||||
|
@PostMapping("/{taskId}/approve")
|
||||||
|
fun approve(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
@PathVariable taskId: String,
|
||||||
|
@RequestHeader("Idempotency-Key") idempotencyKey: String,
|
||||||
|
@Valid @RequestBody request: ApprovalDecisionRequest,
|
||||||
|
): LeaveRequestResponse = approvalTaskService.approve(
|
||||||
|
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
|
||||||
|
taskId,
|
||||||
|
idempotencyKey,
|
||||||
|
request.version,
|
||||||
|
request.comment,
|
||||||
|
).toResponseModel()
|
||||||
|
|
||||||
|
@PostMapping("/{taskId}/reject")
|
||||||
|
fun reject(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
@PathVariable taskId: String,
|
||||||
|
@RequestHeader("Idempotency-Key") idempotencyKey: String,
|
||||||
|
@Valid @RequestBody request: ApprovalDecisionRequest,
|
||||||
|
): LeaveRequestResponse = approvalTaskService.reject(
|
||||||
|
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
|
||||||
|
taskId,
|
||||||
|
idempotencyKey,
|
||||||
|
request.version,
|
||||||
|
request.comment,
|
||||||
|
).toResponseModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ApprovalDecisionRequest(
|
||||||
|
@field:PositiveOrZero
|
||||||
|
val version: Long,
|
||||||
|
@field:Size(max = 1000)
|
||||||
|
val comment: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApprovalTaskResponse(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val leaveRequest: LeaveRequestResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ApprovalTask.toResponse() = ApprovalTaskResponse(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
createdAt = createdAt,
|
||||||
|
leaveRequest = leaveRequest.toResponseModel(),
|
||||||
|
)
|
||||||
+11
-7
@@ -40,7 +40,7 @@ class LeaveRequestController(
|
|||||||
@Valid @RequestBody request: SaveDraftRequest,
|
@Valid @RequestBody request: SaveDraftRequest,
|
||||||
): LeaveRequestResponse = leaveRequestService
|
): LeaveRequestResponse = leaveRequestService
|
||||||
.createDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), idempotencyKey, request.toCommand())
|
.createDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), idempotencyKey, request.toCommand())
|
||||||
.toResponse()
|
.toResponseModel()
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
fun updateDraft(
|
fun updateDraft(
|
||||||
@@ -49,7 +49,7 @@ class LeaveRequestController(
|
|||||||
@Valid @RequestBody request: SaveDraftRequest,
|
@Valid @RequestBody request: SaveDraftRequest,
|
||||||
): LeaveRequestResponse = leaveRequestService
|
): LeaveRequestResponse = leaveRequestService
|
||||||
.updateDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.toCommand())
|
.updateDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.toCommand())
|
||||||
.toResponse()
|
.toResponseModel()
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
fun getOwn(
|
fun getOwn(
|
||||||
@@ -57,12 +57,12 @@ class LeaveRequestController(
|
|||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
): LeaveRequestResponse = leaveRequestService
|
): LeaveRequestResponse = leaveRequestService
|
||||||
.getOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
|
.getOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
|
||||||
.toResponse()
|
.toResponseModel()
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun listOwn(@AuthenticationPrincipal jwt: Jwt): List<LeaveRequestResponse> = leaveRequestService
|
fun listOwn(@AuthenticationPrincipal jwt: Jwt): List<LeaveRequestResponse> = leaveRequestService
|
||||||
.listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
|
.listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
|
||||||
.map(LeaveRequest::toResponse)
|
.map(LeaveRequest::toResponseModel)
|
||||||
|
|
||||||
@PostMapping("/{id}/submit")
|
@PostMapping("/{id}/submit")
|
||||||
fun submit(
|
fun submit(
|
||||||
@@ -76,7 +76,7 @@ class LeaveRequestController(
|
|||||||
id,
|
id,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
request.version,
|
request.version,
|
||||||
).toResponse()
|
).toResponseModel()
|
||||||
|
|
||||||
@PostMapping("/{id}/withdraw")
|
@PostMapping("/{id}/withdraw")
|
||||||
fun withdraw(
|
fun withdraw(
|
||||||
@@ -90,7 +90,7 @@ class LeaveRequestController(
|
|||||||
id,
|
id,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
request.version,
|
request.version,
|
||||||
).toResponse()
|
).toResponseModel()
|
||||||
|
|
||||||
@GetMapping("/{id}/timeline")
|
@GetMapping("/{id}/timeline")
|
||||||
fun timeline(
|
fun timeline(
|
||||||
@@ -125,6 +125,8 @@ data class LeaveRequestResponse(
|
|||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
val updatedAt: Instant,
|
val updatedAt: Instant,
|
||||||
val version: Long,
|
val version: Long,
|
||||||
|
val processInstanceId: String?,
|
||||||
|
val processDefinitionId: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class TransitionRequest(
|
data class TransitionRequest(
|
||||||
@@ -142,7 +144,7 @@ data class LeaveRequestEventResponse(
|
|||||||
val occurredAt: Instant,
|
val occurredAt: Instant,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun LeaveRequest.toResponse() = LeaveRequestResponse(
|
internal fun LeaveRequest.toResponseModel() = LeaveRequestResponse(
|
||||||
id = id,
|
id = id,
|
||||||
applicantId = applicantId,
|
applicantId = applicantId,
|
||||||
type = type,
|
type = type,
|
||||||
@@ -153,6 +155,8 @@ private fun LeaveRequest.toResponse() = LeaveRequestResponse(
|
|||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = updatedAt,
|
updatedAt = updatedAt,
|
||||||
version = version,
|
version = version,
|
||||||
|
processInstanceId = processInstanceId,
|
||||||
|
processDefinitionId = processDefinitionId,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun LeaveRequestEvent.toResponse() = LeaveRequestEventResponse(
|
private fun LeaveRequestEvent.toResponse() = LeaveRequestEventResponse(
|
||||||
|
|||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
package com.all8ai.aioa.approval.application
|
||||||
|
|
||||||
|
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||||
|
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||||
|
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||||
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.shared.id.UuidV7
|
||||||
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
|
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||||
|
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||||
|
import org.slf4j.MDC
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ApprovalTaskService(
|
||||||
|
private val workflowGateway: LeaveWorkflowGateway,
|
||||||
|
private val leaveRequestRepository: LeaveRequestRepository,
|
||||||
|
private val auditService: AuditService,
|
||||||
|
) {
|
||||||
|
fun listAssigned(actor: CurrentUser): List<ApprovalTask> =
|
||||||
|
workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
|
||||||
|
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
|
||||||
|
?: return@mapNotNull null
|
||||||
|
if (request.status != LeaveStatus.PENDING) return@mapNotNull null
|
||||||
|
ApprovalTask(task.id, task.name, task.createdAt, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun approve(
|
||||||
|
actor: CurrentUser,
|
||||||
|
taskId: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
expectedVersion: Long,
|
||||||
|
comment: String?,
|
||||||
|
): LeaveRequest = decide(actor, taskId, idempotencyKey, expectedVersion, comment, approved = true)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun reject(
|
||||||
|
actor: CurrentUser,
|
||||||
|
taskId: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
expectedVersion: Long,
|
||||||
|
comment: String?,
|
||||||
|
): LeaveRequest = decide(actor, taskId, idempotencyKey, expectedVersion, comment, approved = false)
|
||||||
|
|
||||||
|
private fun decide(
|
||||||
|
actor: CurrentUser,
|
||||||
|
taskId: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
expectedVersion: Long,
|
||||||
|
comment: String?,
|
||||||
|
approved: Boolean,
|
||||||
|
): LeaveRequest {
|
||||||
|
validate(idempotencyKey, expectedVersion, comment)
|
||||||
|
val task = workflowGateway.resolveTask(taskId)
|
||||||
|
?: throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
|
||||||
|
if (task.assigneeId != actor.id) {
|
||||||
|
throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
|
||||||
|
}
|
||||||
|
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
|
||||||
|
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
||||||
|
if (request.applicantId == actor.id) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "SELF_APPROVAL_NOT_ALLOWED", "申请人不能审批自己的申请")
|
||||||
|
}
|
||||||
|
|
||||||
|
val operation = if (approved) "LEAVE_APPROVE" else "LEAVE_REJECT"
|
||||||
|
val action = if (approved) "LEAVE_REQUEST_APPROVED" else "LEAVE_REQUEST_REJECTED"
|
||||||
|
val targetStatus = if (approved) LeaveStatus.APPROVED else LeaveStatus.REJECTED
|
||||||
|
val fingerprint = fingerprint(operation, taskId, expectedVersion, comment)
|
||||||
|
val claim = leaveRequestRepository.claimOperation(
|
||||||
|
UuidV7.generate(), actor.tenantId, actor.id, operation,
|
||||||
|
idempotencyKey, fingerprint, request.id,
|
||||||
|
)
|
||||||
|
if (claim.fingerprint != fingerprint || claim.resourceId != request.id) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数")
|
||||||
|
}
|
||||||
|
if (!claim.claimed) {
|
||||||
|
return leaveRequestRepository.findById(actor.tenantId, request.id)!!
|
||||||
|
}
|
||||||
|
if (task.completed) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "APPROVAL_TASK_COMPLETED", "审批任务已处理")
|
||||||
|
}
|
||||||
|
|
||||||
|
val transitioned = leaveRequestRepository.transitionForApprover(
|
||||||
|
tenantId = actor.tenantId,
|
||||||
|
actorId = actor.id,
|
||||||
|
id = request.id,
|
||||||
|
expectedVersion = expectedVersion,
|
||||||
|
toStatus = targetStatus,
|
||||||
|
eventId = UuidV7.generate(),
|
||||||
|
eventType = action,
|
||||||
|
traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown",
|
||||||
|
) ?: transitionFailure(actor, request.id, expectedVersion)
|
||||||
|
|
||||||
|
workflowGateway.completeTask(taskId, approved, comment)
|
||||||
|
auditService.recordSuccess(
|
||||||
|
actor = actor,
|
||||||
|
action = action,
|
||||||
|
resourceType = "LEAVE_REQUEST",
|
||||||
|
resourceId = request.id.toString(),
|
||||||
|
idempotencyKey = idempotencyKey,
|
||||||
|
details = mapOf(
|
||||||
|
"taskId" to taskId,
|
||||||
|
"toStatus" to targetStatus.name,
|
||||||
|
"version" to transitioned.version,
|
||||||
|
"comment" to comment?.trim(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return transitioned
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transitionFailure(actor: CurrentUser, id: java.util.UUID, expectedVersion: Long): Nothing {
|
||||||
|
val existing = leaveRequestRepository.findById(actor.tenantId, id)
|
||||||
|
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
||||||
|
if (existing.status != LeaveStatus.PENDING) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "LEAVE_STATUS_INVALID", "当前申请状态不允许审批")
|
||||||
|
}
|
||||||
|
if (existing.version != expectedVersion) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试")
|
||||||
|
}
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "LEAVE_TRANSITION_CONFLICT", "审批状态转换失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validate(idempotencyKey: String, version: Long, comment: String?) {
|
||||||
|
if (idempotencyKey.length !in 16..128 || !idempotencyKey.matches(Regex("[A-Za-z0-9._:-]+"))) {
|
||||||
|
throw ApiException(HttpStatus.BAD_REQUEST, "IDEMPOTENCY_KEY_INVALID", "幂等键格式无效")
|
||||||
|
}
|
||||||
|
if (version < 0) throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零")
|
||||||
|
if (comment != null && comment.trim().length > 1000) {
|
||||||
|
throw ApiException(HttpStatus.BAD_REQUEST, "APPROVAL_COMMENT_INVALID", "审批意见不能超过 1000 个字符")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fingerprint(operation: String, taskId: String, version: Long, comment: String?): String {
|
||||||
|
val canonical = "$operation|$taskId|$version|${comment?.trim().orEmpty()}"
|
||||||
|
return MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(canonical.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ApprovalTask(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val createdAt: java.time.Instant,
|
||||||
|
val leaveRequest: LeaveRequest,
|
||||||
|
)
|
||||||
+47
-7
@@ -4,6 +4,7 @@ import com.all8ai.aioa.approval.domain.DraftContent
|
|||||||
import com.all8ai.aioa.approval.domain.LeaveRequest
|
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||||
import com.all8ai.aioa.approval.domain.LeaveRequestEvent
|
import com.all8ai.aioa.approval.domain.LeaveRequestEvent
|
||||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||||
|
import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository
|
||||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||||
import com.all8ai.aioa.approval.domain.LeaveType
|
import com.all8ai.aioa.approval.domain.LeaveType
|
||||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
@@ -15,6 +16,7 @@ import org.springframework.stereotype.Service
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import org.slf4j.MDC
|
import org.slf4j.MDC
|
||||||
import com.all8ai.aioa.shared.web.TraceIdFilter
|
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||||
|
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -24,6 +26,8 @@ import java.util.UUID
|
|||||||
class LeaveRequestService(
|
class LeaveRequestService(
|
||||||
private val repository: LeaveRequestRepository,
|
private val repository: LeaveRequestRepository,
|
||||||
private val auditService: AuditService,
|
private val auditService: AuditService,
|
||||||
|
private val routingRepository: ApprovalRoutingRepository,
|
||||||
|
private val workflowGateway: LeaveWorkflowGateway,
|
||||||
) {
|
) {
|
||||||
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
||||||
validateIdempotencyKey(idempotencyKey)
|
validateIdempotencyKey(idempotencyKey)
|
||||||
@@ -66,8 +70,13 @@ class LeaveRequestService(
|
|||||||
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100)
|
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100)
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest =
|
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
||||||
transition(
|
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
|
||||||
|
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
||||||
|
if (approverId == actor.id) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "SELF_APPROVAL_NOT_ALLOWED", "申请人不能审批自己的申请")
|
||||||
|
}
|
||||||
|
val outcome = transition(
|
||||||
actor = actor,
|
actor = actor,
|
||||||
id = id,
|
id = id,
|
||||||
idempotencyKey = idempotencyKey,
|
idempotencyKey = idempotencyKey,
|
||||||
@@ -77,10 +86,29 @@ class LeaveRequestService(
|
|||||||
fromStatus = LeaveStatus.DRAFT,
|
fromStatus = LeaveStatus.DRAFT,
|
||||||
toStatus = LeaveStatus.PENDING,
|
toStatus = LeaveStatus.PENDING,
|
||||||
)
|
)
|
||||||
|
if (outcome.replayed) return outcome.leaveRequest
|
||||||
|
|
||||||
|
val process = workflowGateway.startLeaveApproval(
|
||||||
|
actor.tenantId,
|
||||||
|
outcome.leaveRequest.id,
|
||||||
|
actor.id,
|
||||||
|
approverId,
|
||||||
|
)
|
||||||
|
repository.attachWorkflow(
|
||||||
|
actor.tenantId,
|
||||||
|
outcome.leaveRequest.id,
|
||||||
|
process.processInstanceId,
|
||||||
|
process.processDefinitionId,
|
||||||
|
)
|
||||||
|
return outcome.leaveRequest.copy(
|
||||||
|
processInstanceId = process.processInstanceId,
|
||||||
|
processDefinitionId = process.processDefinitionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest =
|
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
||||||
transition(
|
val outcome = transition(
|
||||||
actor = actor,
|
actor = actor,
|
||||||
id = id,
|
id = id,
|
||||||
idempotencyKey = idempotencyKey,
|
idempotencyKey = idempotencyKey,
|
||||||
@@ -90,6 +118,13 @@ class LeaveRequestService(
|
|||||||
fromStatus = LeaveStatus.PENDING,
|
fromStatus = LeaveStatus.PENDING,
|
||||||
toStatus = LeaveStatus.WITHDRAWN,
|
toStatus = LeaveStatus.WITHDRAWN,
|
||||||
)
|
)
|
||||||
|
if (!outcome.replayed) {
|
||||||
|
outcome.leaveRequest.processInstanceId?.let {
|
||||||
|
workflowGateway.cancelProcess(it, "Applicant withdrew leave request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcome.leaveRequest
|
||||||
|
}
|
||||||
|
|
||||||
fun timeline(actor: CurrentUser, id: UUID): List<LeaveRequestEvent> {
|
fun timeline(actor: CurrentUser, id: UUID): List<LeaveRequestEvent> {
|
||||||
getOwn(actor, id)
|
getOwn(actor, id)
|
||||||
@@ -105,7 +140,7 @@ class LeaveRequestService(
|
|||||||
action: String,
|
action: String,
|
||||||
fromStatus: LeaveStatus,
|
fromStatus: LeaveStatus,
|
||||||
toStatus: LeaveStatus,
|
toStatus: LeaveStatus,
|
||||||
): LeaveRequest {
|
): TransitionOutcome {
|
||||||
validateIdempotencyKey(idempotencyKey)
|
validateIdempotencyKey(idempotencyKey)
|
||||||
if (expectedVersion < 0) {
|
if (expectedVersion < 0) {
|
||||||
throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零")
|
throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零")
|
||||||
@@ -119,7 +154,7 @@ class LeaveRequestService(
|
|||||||
throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数")
|
throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数")
|
||||||
}
|
}
|
||||||
if (!claim.claimed) {
|
if (!claim.claimed) {
|
||||||
return getOwn(actor, id)
|
return TransitionOutcome(getOwn(actor, id), replayed = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown"
|
val traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown"
|
||||||
@@ -140,7 +175,7 @@ class LeaveRequestService(
|
|||||||
"version" to transitioned.version,
|
"version" to transitioned.version,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return transitioned
|
return TransitionOutcome(transitioned, replayed = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun transitionFailure(
|
private fun transitionFailure(
|
||||||
@@ -192,6 +227,11 @@ class LeaveRequestService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class TransitionOutcome(
|
||||||
|
val leaveRequest: LeaveRequest,
|
||||||
|
val replayed: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
data class SaveDraftCommand(
|
data class SaveDraftCommand(
|
||||||
val type: LeaveType,
|
val type: LeaveType,
|
||||||
val startsAt: Instant,
|
val startsAt: Instant,
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.all8ai.aioa.approval.domain
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
fun interface ApprovalRoutingRepository {
|
||||||
|
fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID?
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ data class LeaveRequest(
|
|||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
val updatedAt: Instant,
|
val updatedAt: Instant,
|
||||||
val version: Long,
|
val version: Long,
|
||||||
|
val processInstanceId: String? = null,
|
||||||
|
val processDefinitionId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DraftContent(
|
data class DraftContent(
|
||||||
@@ -83,6 +85,26 @@ interface LeaveRequestRepository {
|
|||||||
): LeaveRequest?
|
): LeaveRequest?
|
||||||
|
|
||||||
fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent>
|
fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent>
|
||||||
|
|
||||||
|
fun attachWorkflow(
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
processInstanceId: String,
|
||||||
|
processDefinitionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun findById(tenantId: UUID, id: UUID): LeaveRequest?
|
||||||
|
|
||||||
|
fun transitionForApprover(
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
id: UUID,
|
||||||
|
expectedVersion: Long,
|
||||||
|
toStatus: LeaveStatus,
|
||||||
|
eventId: UUID,
|
||||||
|
eventType: String,
|
||||||
|
traceId: String,
|
||||||
|
): LeaveRequest?
|
||||||
}
|
}
|
||||||
|
|
||||||
data class IdempotentDraft(
|
data class IdempotentDraft(
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.all8ai.aioa.approval.infrastructure
|
||||||
|
|
||||||
|
import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class JooqApprovalRoutingRepository(
|
||||||
|
private val dsl: DSLContext,
|
||||||
|
) : ApprovalRoutingRepository {
|
||||||
|
override fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID? =
|
||||||
|
dsl.fetchOne(
|
||||||
|
"""
|
||||||
|
SELECT manager.user_id
|
||||||
|
FROM organization.user_assignment applicant
|
||||||
|
JOIN organization.user_assignment manager
|
||||||
|
ON manager.tenant_id = applicant.tenant_id
|
||||||
|
AND manager.department_id = applicant.department_id
|
||||||
|
AND manager.effective_from <= CURRENT_TIMESTAMP
|
||||||
|
AND (manager.effective_until IS NULL OR manager.effective_until > CURRENT_TIMESTAMP)
|
||||||
|
JOIN organization.position position
|
||||||
|
ON position.tenant_id = manager.tenant_id
|
||||||
|
AND position.id = manager.position_id
|
||||||
|
AND position.code = 'manager'
|
||||||
|
AND position.status = 'ACTIVE'
|
||||||
|
JOIN identity.user_account account
|
||||||
|
ON account.tenant_id = manager.tenant_id
|
||||||
|
AND account.id = manager.user_id
|
||||||
|
AND account.status = 'ACTIVE'
|
||||||
|
WHERE applicant.tenant_id = ?
|
||||||
|
AND applicant.user_id = ?
|
||||||
|
AND applicant.is_primary = TRUE
|
||||||
|
AND applicant.effective_from <= CURRENT_TIMESTAMP
|
||||||
|
AND (applicant.effective_until IS NULL OR applicant.effective_until > CURRENT_TIMESTAMP)
|
||||||
|
ORDER BY manager.is_primary DESC, manager.created_at
|
||||||
|
LIMIT 1
|
||||||
|
""".trimIndent(),
|
||||||
|
tenantId,
|
||||||
|
applicantId,
|
||||||
|
)?.get("user_id", UUID::class.java)
|
||||||
|
}
|
||||||
+72
@@ -221,6 +221,76 @@ class JooqLeaveRequestRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun attachWorkflow(
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
processInstanceId: String,
|
||||||
|
processDefinitionId: String,
|
||||||
|
) {
|
||||||
|
val updated = dsl.execute(
|
||||||
|
"""
|
||||||
|
UPDATE business.leave_request
|
||||||
|
SET process_instance_id = ?, process_definition_id = ?
|
||||||
|
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'
|
||||||
|
AND process_instance_id IS NULL
|
||||||
|
""".trimIndent(),
|
||||||
|
processInstanceId,
|
||||||
|
processDefinitionId,
|
||||||
|
tenantId,
|
||||||
|
leaveRequestId,
|
||||||
|
)
|
||||||
|
check(updated == 1) { "Unable to attach workflow to leave request $leaveRequestId" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findById(tenantId: UUID, id: UUID): LeaveRequest? =
|
||||||
|
dsl.fetchOne(
|
||||||
|
"SELECT * FROM business.leave_request WHERE tenant_id = ? AND id = ?",
|
||||||
|
tenantId,
|
||||||
|
id,
|
||||||
|
)?.let(::map)
|
||||||
|
|
||||||
|
override fun transitionForApprover(
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
id: UUID,
|
||||||
|
expectedVersion: Long,
|
||||||
|
toStatus: LeaveStatus,
|
||||||
|
eventId: UUID,
|
||||||
|
eventType: String,
|
||||||
|
traceId: String,
|
||||||
|
): LeaveRequest? {
|
||||||
|
val transitioned = dsl.fetchOne(
|
||||||
|
"""
|
||||||
|
UPDATE business.leave_request
|
||||||
|
SET status = ?, completed_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||||
|
WHERE tenant_id = ? AND id = ? AND status = 'PENDING' AND version = ?
|
||||||
|
RETURNING *
|
||||||
|
""".trimIndent(),
|
||||||
|
toStatus.name,
|
||||||
|
tenantId,
|
||||||
|
id,
|
||||||
|
expectedVersion,
|
||||||
|
)?.let(::map) ?: return null
|
||||||
|
|
||||||
|
dsl.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO business.leave_request_event (
|
||||||
|
id, tenant_id, leave_request_id, actor_id, event_type,
|
||||||
|
from_status, to_status, trace_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 'PENDING', ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
eventId,
|
||||||
|
tenantId,
|
||||||
|
id,
|
||||||
|
actorId,
|
||||||
|
eventType,
|
||||||
|
toStatus.name,
|
||||||
|
traceId,
|
||||||
|
)
|
||||||
|
return transitioned
|
||||||
|
}
|
||||||
|
|
||||||
private fun map(record: Record): LeaveRequest = LeaveRequest(
|
private fun map(record: Record): LeaveRequest = LeaveRequest(
|
||||||
id = record.get("id", UUID::class.java)!!,
|
id = record.get("id", UUID::class.java)!!,
|
||||||
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
||||||
@@ -233,5 +303,7 @@ class JooqLeaveRequestRepository(
|
|||||||
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||||
updatedAt = record.get("updated_at", OffsetDateTime::class.java)!!.toInstant(),
|
updatedAt = record.get("updated_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||||
version = record.get("version", Long::class.java)!!,
|
version = record.get("version", Long::class.java)!!,
|
||||||
|
processInstanceId = record.get("process_instance_id", String::class.java),
|
||||||
|
processDefinitionId = record.get("process_definition_id", String::class.java),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.all8ai.aioa.workflow.domain
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface LeaveWorkflowGateway {
|
||||||
|
fun startLeaveApproval(
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
applicantId: UUID,
|
||||||
|
approverId: UUID,
|
||||||
|
): StartedProcess
|
||||||
|
|
||||||
|
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
||||||
|
|
||||||
|
fun resolveTask(taskId: String): WorkflowTask?
|
||||||
|
|
||||||
|
fun completeTask(taskId: String, approved: Boolean, comment: String?)
|
||||||
|
|
||||||
|
fun cancelProcess(processInstanceId: String, reason: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StartedProcess(
|
||||||
|
val processInstanceId: String,
|
||||||
|
val processDefinitionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WorkflowTask(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val processInstanceId: String,
|
||||||
|
val leaveRequestId: UUID,
|
||||||
|
val assigneeId: UUID,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val completed: Boolean,
|
||||||
|
)
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package com.all8ai.aioa.workflow.infrastructure
|
||||||
|
|
||||||
|
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||||
|
import com.all8ai.aioa.workflow.domain.StartedProcess
|
||||||
|
import com.all8ai.aioa.workflow.domain.WorkflowTask
|
||||||
|
import org.flowable.engine.HistoryService
|
||||||
|
import org.flowable.engine.RuntimeService
|
||||||
|
import org.flowable.engine.TaskService
|
||||||
|
import org.flowable.task.api.Task
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class FlowableLeaveWorkflowGateway(
|
||||||
|
private val runtimeService: RuntimeService,
|
||||||
|
private val taskService: TaskService,
|
||||||
|
private val historyService: HistoryService,
|
||||||
|
) : LeaveWorkflowGateway {
|
||||||
|
override fun startLeaveApproval(
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
applicantId: UUID,
|
||||||
|
approverId: UUID,
|
||||||
|
): StartedProcess {
|
||||||
|
val process = runtimeService.createProcessInstanceBuilder()
|
||||||
|
.processDefinitionKey(PROCESS_DEFINITION_KEY)
|
||||||
|
.businessKey(leaveRequestId.toString())
|
||||||
|
.variables(
|
||||||
|
mapOf(
|
||||||
|
"tenantId" to tenantId.toString(),
|
||||||
|
"businessId" to leaveRequestId.toString(),
|
||||||
|
"applicantId" to applicantId.toString(),
|
||||||
|
"approverId" to approverId.toString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.start()
|
||||||
|
return StartedProcess(process.id, process.processDefinitionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> =
|
||||||
|
taskService.createTaskQuery()
|
||||||
|
.taskAssignee(assigneeId.toString())
|
||||||
|
.active()
|
||||||
|
.includeProcessVariables()
|
||||||
|
.orderByTaskCreateTime()
|
||||||
|
.desc()
|
||||||
|
.list()
|
||||||
|
.map(::mapActiveTask)
|
||||||
|
|
||||||
|
override fun resolveTask(taskId: String): WorkflowTask? {
|
||||||
|
taskService.createTaskQuery()
|
||||||
|
.taskId(taskId)
|
||||||
|
.includeProcessVariables()
|
||||||
|
.singleResult()
|
||||||
|
?.let { return mapActiveTask(it) }
|
||||||
|
|
||||||
|
val historic = historyService.createHistoricTaskInstanceQuery()
|
||||||
|
.taskId(taskId)
|
||||||
|
.includeProcessVariables()
|
||||||
|
.singleResult()
|
||||||
|
?: return null
|
||||||
|
return WorkflowTask(
|
||||||
|
id = historic.id,
|
||||||
|
name = historic.name,
|
||||||
|
processInstanceId = historic.processInstanceId,
|
||||||
|
leaveRequestId = UUID.fromString(historic.processVariables["businessId"] as String),
|
||||||
|
assigneeId = UUID.fromString(historic.assignee),
|
||||||
|
createdAt = historic.createTime.toInstant(),
|
||||||
|
completed = historic.endTime != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun completeTask(taskId: String, approved: Boolean, comment: String?) {
|
||||||
|
if (!comment.isNullOrBlank()) {
|
||||||
|
val task = taskService.createTaskQuery().taskId(taskId).singleResult()
|
||||||
|
?: error("Active workflow task not found: $taskId")
|
||||||
|
taskService.addComment(taskId, task.processInstanceId, comment.trim())
|
||||||
|
}
|
||||||
|
taskService.complete(taskId, mapOf("approved" to approved))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelProcess(processInstanceId: String, reason: String) {
|
||||||
|
runtimeService.deleteProcessInstance(processInstanceId, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapActiveTask(task: Task): WorkflowTask = WorkflowTask(
|
||||||
|
id = task.id,
|
||||||
|
name = task.name,
|
||||||
|
processInstanceId = task.processInstanceId,
|
||||||
|
leaveRequestId = UUID.fromString(task.processVariables["businessId"] as String),
|
||||||
|
assigneeId = UUID.fromString(task.assignee),
|
||||||
|
createdAt = task.createTime.toInstant(),
|
||||||
|
completed = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PROCESS_DEFINITION_KEY = "leaveApproval"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,3 +38,9 @@ management:
|
|||||||
logging:
|
logging:
|
||||||
pattern:
|
pattern:
|
||||||
correlation: "[traceId=%X{traceId:-}] "
|
correlation: "[traceId=%X{traceId:-}] "
|
||||||
|
|
||||||
|
flowable:
|
||||||
|
database-schema: flowable
|
||||||
|
database-schema-update: ${FLOWABLE_SCHEMA_UPDATE:true}
|
||||||
|
async-executor-activate: false
|
||||||
|
history-level: audit
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE business.leave_request
|
||||||
|
ADD COLUMN process_instance_id VARCHAR(64),
|
||||||
|
ADD COLUMN process_definition_id VARCHAR(128);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_leave_request_process_instance
|
||||||
|
ON business.leave_request (process_instance_id)
|
||||||
|
WHERE process_instance_id IS NOT NULL;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:flowable="http://flowable.org/bpmn"
|
||||||
|
targetNamespace="https://aioa.all8ai.com/processes">
|
||||||
|
<process id="leaveApproval" name="请假审批" isExecutable="true">
|
||||||
|
<startEvent id="start" name="申请已提交"/>
|
||||||
|
<sequenceFlow id="flow-start-review" sourceRef="start" targetRef="managerReview"/>
|
||||||
|
|
||||||
|
<userTask id="managerReview"
|
||||||
|
name="部门主管审批"
|
||||||
|
flowable:assignee="${approverId}"/>
|
||||||
|
<sequenceFlow id="flow-review-decision" sourceRef="managerReview" targetRef="decision"/>
|
||||||
|
|
||||||
|
<exclusiveGateway id="decision" name="审批结果"/>
|
||||||
|
<sequenceFlow id="flow-approved" sourceRef="decision" targetRef="approvedEnd">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
<sequenceFlow id="flow-rejected" sourceRef="decision" targetRef="rejectedEnd">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
|
||||||
|
<endEvent id="approvedEnd" name="已批准"/>
|
||||||
|
<endEvent id="rejectedEnd" name="已驳回"/>
|
||||||
|
</process>
|
||||||
|
</definitions>
|
||||||
+53
-1
@@ -11,6 +11,10 @@ import com.all8ai.aioa.approval.domain.OperationClaim
|
|||||||
import com.all8ai.aioa.audit.application.AuditService
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
||||||
import com.all8ai.aioa.audit.domain.AuditEvent
|
import com.all8ai.aioa.audit.domain.AuditEvent
|
||||||
|
import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository
|
||||||
|
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||||
|
import com.all8ai.aioa.workflow.domain.StartedProcess
|
||||||
|
import com.all8ai.aioa.workflow.domain.WorkflowTask
|
||||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
import com.all8ai.aioa.shared.web.ApiException
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
@@ -123,7 +127,26 @@ class LeaveRequestServiceTest {
|
|||||||
private fun service(
|
private fun service(
|
||||||
repository: LeaveRequestRepository,
|
repository: LeaveRequestRepository,
|
||||||
auditRepository: AuditEventRepository = AuditEventRepository { },
|
auditRepository: AuditEventRepository = AuditEventRepository { },
|
||||||
) = LeaveRequestService(repository, AuditService(auditRepository))
|
) = LeaveRequestService(
|
||||||
|
repository,
|
||||||
|
AuditService(auditRepository),
|
||||||
|
ApprovalRoutingRepository { _, _ -> UUID.fromString("40000000-0000-7000-8000-000000000002") },
|
||||||
|
FakeWorkflowGateway(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeWorkflowGateway : LeaveWorkflowGateway {
|
||||||
|
override fun startLeaveApproval(
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
applicantId: UUID,
|
||||||
|
approverId: UUID,
|
||||||
|
) = StartedProcess("process-$leaveRequestId", "leaveApproval:1:test")
|
||||||
|
|
||||||
|
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> = emptyList()
|
||||||
|
override fun resolveTask(taskId: String): WorkflowTask? = null
|
||||||
|
override fun completeTask(taskId: String, approved: Boolean, comment: String?) = Unit
|
||||||
|
override fun cancelProcess(processInstanceId: String, reason: String) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
private class CapturingAuditRepository : AuditEventRepository {
|
private class CapturingAuditRepository : AuditEventRepository {
|
||||||
val events = mutableListOf<AuditEvent>()
|
val events = mutableListOf<AuditEvent>()
|
||||||
@@ -214,5 +237,34 @@ class LeaveRequestServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent> = events.toList()
|
override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent> = events.toList()
|
||||||
|
|
||||||
|
override fun attachWorkflow(
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
processInstanceId: String,
|
||||||
|
processDefinitionId: String,
|
||||||
|
) {
|
||||||
|
val entry = drafts.entries.first { it.value.leaveRequest.id == leaveRequestId }
|
||||||
|
drafts[entry.key] = entry.value.copy(
|
||||||
|
leaveRequest = entry.value.leaveRequest.copy(
|
||||||
|
processInstanceId = processInstanceId,
|
||||||
|
processDefinitionId = processDefinitionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findById(tenantId: UUID, id: UUID): LeaveRequest? =
|
||||||
|
drafts.values.map { it.leaveRequest }.firstOrNull { it.tenantId == tenantId && it.id == id }
|
||||||
|
|
||||||
|
override fun transitionForApprover(
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
id: UUID,
|
||||||
|
expectedVersion: Long,
|
||||||
|
toStatus: LeaveStatus,
|
||||||
|
eventId: UUID,
|
||||||
|
eventType: String,
|
||||||
|
traceId: String,
|
||||||
|
): LeaveRequest? = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("org.springframework.boot") version "3.5.3" apply false
|
id("org.springframework.boot") version "3.5.4" apply false
|
||||||
id("io.spring.dependency-management") version "1.1.7" apply false
|
id("io.spring.dependency-management") version "1.1.7" apply false
|
||||||
kotlin("jvm") version "2.1.21" apply false
|
kotlin("jvm") version "2.1.21" apply false
|
||||||
kotlin("plugin.spring") version "2.1.21" apply false
|
kotlin("plugin.spring") version "2.1.21" apply false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: AIOA API
|
title: AIOA API
|
||||||
version: 0.3.0
|
version: 0.4.0
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
paths:
|
paths:
|
||||||
@@ -169,6 +169,67 @@ paths:
|
|||||||
$ref: "#/components/schemas/LeaveRequestEvent"
|
$ref: "#/components/schemas/LeaveRequestEvent"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
|
/approval-tasks:
|
||||||
|
get:
|
||||||
|
operationId: listAssignedApprovalTasks
|
||||||
|
summary: 查询分配给当前用户的审批待办
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 待办列表
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ApprovalTask"
|
||||||
|
/approval-tasks/{taskId}/approve:
|
||||||
|
post:
|
||||||
|
operationId: approveTask
|
||||||
|
summary: 批准审批任务
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ApprovalTaskId"
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ApprovalDecision"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 已批准或幂等重放
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LeaveRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
|
/approval-tasks/{taskId}/reject:
|
||||||
|
post:
|
||||||
|
operationId: rejectTask
|
||||||
|
summary: 驳回审批任务
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ApprovalTaskId"
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ApprovalDecision"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 已驳回或幂等重放
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LeaveRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
LeaveRequestId:
|
LeaveRequestId:
|
||||||
@@ -184,6 +245,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
minLength: 16
|
minLength: 16
|
||||||
maxLength: 128
|
maxLength: 128
|
||||||
|
ApprovalTaskId:
|
||||||
|
name: taskId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
responses:
|
responses:
|
||||||
BadRequest:
|
BadRequest:
|
||||||
description: 请求无效
|
description: 请求无效
|
||||||
@@ -274,6 +340,10 @@ components:
|
|||||||
version: { type: integer, minimum: 0 }
|
version: { type: integer, minimum: 0 }
|
||||||
createdAt: { type: string, format: date-time }
|
createdAt: { type: string, format: date-time }
|
||||||
updatedAt: { type: string, format: date-time }
|
updatedAt: { type: string, format: date-time }
|
||||||
|
processInstanceId:
|
||||||
|
type: [string, "null"]
|
||||||
|
processDefinitionId:
|
||||||
|
type: [string, "null"]
|
||||||
TransitionRequest:
|
TransitionRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [version]
|
required: [version]
|
||||||
@@ -294,6 +364,23 @@ components:
|
|||||||
actorId: { type: string, format: uuid }
|
actorId: { type: string, format: uuid }
|
||||||
traceId: { type: string }
|
traceId: { type: string }
|
||||||
occurredAt: { type: string, format: date-time }
|
occurredAt: { type: string, format: date-time }
|
||||||
|
ApprovalDecision:
|
||||||
|
type: object
|
||||||
|
required: [version]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
comment:
|
||||||
|
type: [string, "null"]
|
||||||
|
maxLength: 1000
|
||||||
|
ApprovalTask:
|
||||||
|
type: object
|
||||||
|
required: [id, name, createdAt, leaveRequest]
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
name: { type: string }
|
||||||
|
createdAt: { type: string, format: date-time }
|
||||||
|
leaveRequest:
|
||||||
|
$ref: "#/components/schemas/LeaveRequest"
|
||||||
Problem:
|
Problem:
|
||||||
type: object
|
type: object
|
||||||
required: [type, title, status, code, traceId]
|
required: [type, title, status, code, traceId]
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
- [x] 草稿创建、修改、本人查询与列表 API
|
- [x] 草稿创建、修改、本人查询与列表 API
|
||||||
- [x] 创建幂等、租户/用户隔离和乐观锁
|
- [x] 创建幂等、租户/用户隔离和乐观锁
|
||||||
- [x] 提交、撤回、状态时间线和同步审计
|
- [x] 提交、撤回、状态时间线和同步审计
|
||||||
- Flowable 流程发布与执行
|
- [x] Flowable 部门主管审批流程发布与执行
|
||||||
- 发起、待办、批准、驳回、撤回和时间线
|
- [x] 发起、主管待办、批准、驳回、撤回和时间线
|
||||||
- 附件、通知、弱网恢复和幂等处理
|
- 附件、通知、弱网恢复和幂等处理
|
||||||
|
|
||||||
## M3:AI 最小闭环
|
## M3:AI 最小闭环
|
||||||
|
|||||||
Reference in New Issue
Block a user