Просмотр исходного кода

fix(s8): merge exception verification approval flow

YY968XX 1 месяц назад
Родитель
Сommit
fe29af0dbf

+ 0 - 2
Web/src/views/aidop/s8/api/s8ExceptionApi.ts

@@ -86,8 +86,6 @@ export const s8ExceptionApi = {
 		service.post(`/api/aidop/s8/exceptions/${id}/upgrade`, body ?? {}).then(unwrap),
 	reject: (id: number, body?: { remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/reject`, body ?? {}).then(unwrap),
-	close: (id: number, body?: { remark?: string }) =>
-		service.post(`/api/aidop/s8/exceptions/${id}/close`, body ?? {}).then(unwrap),
 	startProgress: (id: number, body?: { remark?: string }) =>
 		service.post(`/api/aidop/s8/exceptions/${id}/start-progress`, body ?? {}).then(unwrap),
 	comment: (id: number, body?: { remark?: string }) =>

+ 23 - 16
Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue

@@ -5,6 +5,9 @@ import { useRoute, useRouter } from 'vue-router';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import ApprovalPanel from '/@/views/approvalFlow/component/ApprovalPanel.vue';
 import { s8ExceptionApi, type S8DecisionRow, type S8EvidenceRow } from '../api/s8ExceptionApi';
+import { useUserInfo } from '/@/stores/userInfo';
+
+const userStore = useUserInfo();
 
 const route = useRoute();
 const router = useRouter();
@@ -13,7 +16,13 @@ const detail = ref<Record<string, any> | null>(null);
 const timeline = ref<{ id: number; actionCode: string; actionLabel: string; operatorName?: string | null; actionRemark?: string | null; createdAt: string }[]>([]);
 const decisions = ref<S8DecisionRow[]>([]);
 const evidences = ref<S8EvidenceRow[]>([]);
-const employees = ref<{ id: number; name: string; empCode?: string; bindStatus?: 'BOUND' | 'UNBOUND'; sysUserName?: string | null }[]>([]);
+const employees = ref<{ id: number; name: string; empCode?: string; bindStatus?: 'BOUND' | 'UNBOUND'; sysUserName?: string | null; sysUserId?: number | null }[]>([]);
+
+const selfEmployee = computed(() => {
+	const uid = Number(userStore.userInfos?.id ?? 0);
+	if (!uid) return null;
+	return employees.value.find((e) => Number(e.sysUserId ?? 0) === uid) ?? null;
+});
 const dialogMode = ref('');
 const dialogVisible = ref(false);
 const submitting = ref(false);
@@ -34,7 +43,6 @@ const canStartProgress = computed(() => currentStatus.value === 'ASSIGNED');
 const canTransfer = computed(() => !['', 'CLOSED'].includes(currentStatus.value));
 const canUpgrade = computed(() => !hasActiveFlow.value && ['ASSIGNED', 'IN_PROGRESS'].includes(currentStatus.value));
 const canReject = computed(() => ['NEW', 'ASSIGNED', 'IN_PROGRESS'].includes(currentStatus.value));
-const canClose = computed(() => !hasActiveFlow.value && currentStatus.value === 'RESOLVED');
 const canSubmitVerification = computed(
 	() => currentStatus.value === 'IN_PROGRESS' && !!detail.value?.assigneeId
 );
@@ -78,7 +86,7 @@ function openAction(mode: string) {
 	dialogMode.value = mode;
 	dialogVisible.value = true;
 	actionForm.remark = '';
-	actionForm.assigneeId = undefined;
+	actionForm.assigneeId = mode === 'claim' ? selfEmployee.value?.id : undefined;
 }
 
 function actionTitle() {
@@ -89,7 +97,6 @@ function actionTitle() {
 			transfer: '转派',
 			upgrade: '升级',
 			reject: '驳回',
-			close: '提交关闭申请',
 			comment: '补充说明',
 			submitVerification: '提交复检',
 			approveVerification: '检验通过',
@@ -105,7 +112,11 @@ async function onApprovalCompleted() {
 async function submitAction() {
 	const id = Number(route.params.id);
 	if (!id) return;
-	if ((dialogMode.value === 'claim' || dialogMode.value === 'transfer') && !actionForm.assigneeId) {
+	if (dialogMode.value === 'claim' && !actionForm.assigneeId) {
+		ElMessage.warning('当前账号未绑定员工,无法认领,请联系管理员');
+		return;
+	}
+	if (dialogMode.value === 'transfer' && !actionForm.assigneeId) {
 		ElMessage.warning('请选择处理人');
 		return;
 	}
@@ -113,10 +124,6 @@ async function submitAction() {
 		ElMessage.warning('请填写升级原因');
 		return;
 	}
-	if (dialogMode.value === 'close' && !actionForm.remark) {
-		ElMessage.warning('请填写处置措施');
-		return;
-	}
 
 	submitting.value = true;
 		try {
@@ -130,8 +137,6 @@ async function submitAction() {
 			await s8ExceptionApi.upgrade(id, { remark: actionForm.remark || undefined });
 		} else if (dialogMode.value === 'reject') {
 			await s8ExceptionApi.reject(id, { remark: actionForm.remark || undefined });
-		} else if (dialogMode.value === 'close') {
-			await s8ExceptionApi.close(id, { remark: actionForm.remark || undefined });
 			} else if (dialogMode.value === 'submitVerification') {
 				if (!actionForm.assigneeId) { ElMessage.warning('请选择检验人'); return; }
 				await s8ExceptionApi.submitVerification(
@@ -207,7 +212,7 @@ onMounted(async () => {
 					<el-card shadow="never">
 						<template #header>操作面板</template>
 							<ApprovalPanel
-								v-if="hasActiveFlow"
+								v-if="hasActiveFlow && activeBizType !== 'EXCEPTION_CLOSURE'"
 								:biz-type="activeBizType"
 								:biz-id="detail.id"
 								@refresh="onApprovalCompleted"
@@ -224,7 +229,6 @@ onMounted(async () => {
 								<el-button type="primary" :disabled="!canSubmitVerification" @click="openAction('submitVerification')">提交复检</el-button>
 								<el-button type="success" :disabled="!canApproveVerification" @click="openAction('approveVerification')">检验通过</el-button>
 								<el-button type="warning" :disabled="!canRejectVerification" @click="openAction('rejectVerification')">检验退回</el-button>
-								<el-button type="success" :disabled="!canClose" @click="openAction('close')">提交关闭申请</el-button>
 								<el-button @click="openAction('comment')">补充说明</el-button>
 							</div>
 							<div class="muted mt12">按钮状态按当前异常状态控制,执行后会刷新详情与时间线。</div>
@@ -315,7 +319,10 @@ onMounted(async () => {
 
 		<el-dialog v-model="dialogVisible" :title="actionTitle()" width="520px">
 			<el-form label-width="100px">
-				<el-form-item v-if="dialogMode === 'claim' || dialogMode === 'transfer'" label="处理人" required>
+				<el-form-item v-if="dialogMode === 'claim'" label="处理人">
+					<span>{{ selfEmployee?.name || '—' }}<span style="color:#999;font-size:12px;margin-left:6px">(当前账号)</span></span>
+				</el-form-item>
+				<el-form-item v-else-if="dialogMode === 'transfer'" label="处理人" required>
 					<el-select v-model="actionForm.assigneeId" style="width: 100%" filterable clearable>
 						<el-option
 							v-for="item in employees"
@@ -350,8 +357,8 @@ onMounted(async () => {
 					</el-select>
 				</el-form-item>
 				<el-form-item
-					:label="dialogMode === 'close' ? '处置措施' : dialogMode === 'upgrade' ? '升级原因' : dialogMode === 'rejectVerification' ? '退回原因' : '备注'"
-					:required="dialogMode === 'upgrade' || dialogMode === 'close' || dialogMode === 'rejectVerification'"
+					:label="dialogMode === 'upgrade' ? '升级原因' : dialogMode === 'rejectVerification' ? '退回原因' : '备注'"
+					:required="dialogMode === 'upgrade' || dialogMode === 'rejectVerification'"
 				>
 					<el-input v-model="actionForm.remark" type="textarea" :rows="4" />
 				</el-form-item>

+ 51 - 15
Web/src/views/approvalFlow/center/components/PendingList.vue

@@ -15,26 +15,38 @@
 				</template>
 			</el-table-column>
 			<el-table-column prop="bizType" label="业务类型" width="140" class-name="mobile-hide" label-class-name="mobile-hide" />
-			<el-table-column prop="bizNo" label="业务单号" width="160" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
+			<el-table-column prop="bizNo" label="业务单号" width="160" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide">
+				<template #default="{ row }">
+					<el-link v-if="(row.bizType === 'EXCEPTION_REPORT' || row.bizType === 'EXCEPTION_CLOSURE') && row.bizId" type="primary" @click="goToException(row.bizId)">
+						{{ row.bizNo }}
+					</el-link>
+					<span v-else>{{ row.bizNo }}</span>
+				</template>
+			</el-table-column>
 			<el-table-column prop="nodeName" label="当前节点" width="140" />
 			<el-table-column prop="initiatorName" label="发起人" width="100" class-name="mobile-hide" label-class-name="mobile-hide" />
 			<el-table-column prop="createTime" label="接收时间" width="180" class-name="mobile-hide" label-class-name="mobile-hide" />
 			<el-table-column label="操作" width="240" align="center" fixed="right">
 				<template #default="{ row }">
-					<el-button size="small" type="success" text @click="emit('approve', row)">同意</el-button>
-					<el-button size="small" type="danger" text @click="emit('reject', row)">拒绝</el-button>
-					<el-dropdown trigger="click" style="margin-left: 8px">
-						<el-button size="small" text>更多</el-button>
-						<template #dropdown>
-							<el-dropdown-menu>
-								<el-dropdown-item @click="emit('transfer', row)">转办</el-dropdown-item>
-								<el-dropdown-item @click="emit('return', row)">退回上一步</el-dropdown-item>
-								<el-dropdown-item @click="emit('addSign', row)">加签</el-dropdown-item>
-								<el-dropdown-item divided @click="emit('urge', row)">催办</el-dropdown-item>
-							</el-dropdown-menu>
-						</template>
-					</el-dropdown>
-					<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
+					<template v-if="row.bizType === 'EXCEPTION_REPORT' || row.bizType === 'EXCEPTION_CLOSURE'">
+						<el-text type="info" size="small">请进入异常详情处理</el-text>
+					</template>
+					<template v-else>
+						<el-button size="small" type="success" text @click="emit('approve', row)">同意</el-button>
+						<el-button size="small" type="danger" text @click="emit('reject', row)">拒绝</el-button>
+						<el-dropdown trigger="click" style="margin-left: 8px">
+							<el-button size="small" text>更多</el-button>
+							<template #dropdown>
+								<el-dropdown-menu>
+									<el-dropdown-item @click="emit('transfer', row)">转办</el-dropdown-item>
+									<el-dropdown-item @click="emit('return', row)">退回上一步</el-dropdown-item>
+									<el-dropdown-item @click="emit('addSign', row)">加签</el-dropdown-item>
+									<el-dropdown-item divided @click="emit('urge', row)">催办</el-dropdown-item>
+								</el-dropdown-menu>
+							</template>
+						</el-dropdown>
+						<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
+					</template>
 				</template>
 			</el-table-column>
 		</el-table>
@@ -43,6 +55,30 @@
 
 <script setup lang="ts">
 import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+import { ElMessageBox } from 'element-plus';
+import { useUserInfo } from '/@/stores/userInfo';
+import { s8ExceptionApi } from '/@/views/aidop/s8/api/s8ExceptionApi';
+
+const router = useRouter();
+const userStore = useUserInfo();
+
+async function goToException(bizId: number | string) {
+	try {
+		const list = (await s8ExceptionApi.employees()) as Array<{ sysUserId?: number | null }>;
+		const uid = Number(userStore.userInfos?.id ?? 0);
+		const bound = uid && list.some((e) => Number(e.sysUserId ?? 0) === uid);
+		if (!bound) {
+			await ElMessageBox.alert('当前账号未绑定员工,找管理员处理', '无法处理异常', { type: 'warning' });
+			return;
+		}
+	} catch {
+		// 查询失败按未绑定处理,避免进入后无法操作
+		await ElMessageBox.alert('无法校验员工绑定状态,请联系管理员', '无法处理异常', { type: 'warning' });
+		return;
+	}
+	router.push(`/aidop/s8/exceptions/${bizId}`);
+}
 
 defineProps<{ data: any[]; loading: boolean }>();
 const emit = defineEmits<{

+ 0 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ExceptionsController.cs

@@ -112,18 +112,6 @@ public class AdoS8ExceptionsController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
-    [HttpPost("{id:long}/close")]
-    public async Task<IActionResult> CloseAsync(long id, [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,
-        [FromBody] AdoS8CommentDto? body = null)
-    {
-        try
-        {
-            var e = await _taskFlowSvc.CloseAsync(id, tenantId, factoryId, body?.Remark);
-            return Ok(new { id = e.Id, status = e.Status });
-        }
-        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
-    }
-
     [HttpPost("{id:long}/submit-verification")]
     public async Task<IActionResult> SubmitVerificationAsync(long id,
         [FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1,

+ 1 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8StatusRules.cs

@@ -12,14 +12,11 @@ public static class S8StatusRules
         ("ASSIGNED", "IN_PROGRESS"),
         ("ASSIGNED", "ESCALATED"),
         ("ASSIGNED", "REJECTED"),
-        ("IN_PROGRESS", "RESOLVED"),
         ("IN_PROGRESS", "PENDING_VERIFICATION"),
-        ("PENDING_VERIFICATION", "RESOLVED"),
+        ("PENDING_VERIFICATION", "CLOSED"),
         ("PENDING_VERIFICATION", "IN_PROGRESS"),
         ("IN_PROGRESS", "ESCALATED"),
         ("IN_PROGRESS", "REJECTED"),
-        ("RESOLVED", "CLOSED"),
-        ("RESOLVED", "IN_PROGRESS"),
         ("ESCALATED", "ASSIGNED"),
         ("ESCALATED", "IN_PROGRESS"),
         ("REJECTED", "NEW"),

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs

@@ -15,7 +15,7 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 /// </summary>
 [JobDetail("job_s8_watch_scheduler", Description = "S8 自动监控主链定时调度",
     GroupName = "default", Concurrent = false)]
-[Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "默认 30 分钟一次")]
+[Period(S8WatchSchedulerJob.IntervalMs, TriggerId = "trigger_s8_watch_scheduler", Description = "演示节奏:5 分钟一次")]
 public class S8WatchSchedulerJob : IJob
 {
     private readonly IServiceScopeFactory _scopeFactory;
@@ -24,7 +24,8 @@ public class S8WatchSchedulerJob : IJob
 
     // G-08 调度周期(毫秒)。属性固化锚点,见 G08-02 配置分类表第 2 项。
     // 既供 [Period] 使用,也供首次激活日志打印,避免两处数值漂移。
-    public const int IntervalMs = 1800000;
+    // 2026-04-28:演示阶段调整为 5 分钟,便于现场看到自动监控效果;R&D 接通"单规则差异化调度"后再回归。
+    public const int IntervalMs = 300000;
 
     // G-08 固定租户 / 工厂上下文。与 G-01 验收口径一致。
     private const long DefaultTenantId = 1;

+ 17 - 27
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/ExceptionClosureBizHandler.cs

@@ -5,8 +5,9 @@ using Admin.NET.Plugin.AiDOP.Entity.S8;
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
 /// <summary>
-/// 异常关闭确认审批业务回调
-/// BizType = "EXCEPTION_CLOSURE"
+/// EXCEPTION_CLOSURE 流程的 Biz 回调(双线合一后已退化为复检审批载体)。
+/// 启动点:S8 提交复检;终态由 S8 业务侧(ApproveVerification/RejectVerification)决定。
+/// 该 handler 只负责维护 ActiveFlowInstanceId 字段,不再改写 e.Status。
 /// </summary>
 public class ExceptionClosureBizHandler : IFlowBizHandler, ITransient
 {
@@ -30,7 +31,7 @@ public class ExceptionClosureBizHandler : IFlowBizHandler, ITransient
         e.ActiveFlowBizType = BizType;
         e.UpdatedAt = DateTime.Now;
         await _rep.UpdateAsync(e);
-        await InsertTimelineAsync(e.Id, "CLOSE_START", "发起关闭确认", null, null, instanceId, null);
+        await InsertTimelineAsync(e.Id, "VERIFY_FLOW_START", "复检审批流启动", instanceId, null);
     }
 
     public async Task OnFlowCompleted(long bizId, long instanceId, FlowInstanceStatusEnum finalStatus, long? lastApproverId)
@@ -39,29 +40,18 @@ public class ExceptionClosureBizHandler : IFlowBizHandler, ITransient
         e.ActiveFlowInstanceId = null;
         e.ActiveFlowBizType = null;
         e.UpdatedAt = DateTime.Now;
+        await _rep.UpdateAsync(e);
 
-        if (finalStatus == FlowInstanceStatusEnum.Approved)
-        {
-            e.Status = "CLOSED";
-            e.ClosedAt = DateTime.Now;
-            await _rep.UpdateAsync(e);
-            await InsertTimelineAsync(e.Id, "CLOSE_APPROVED", "关闭已确认", "RESOLVED", "CLOSED",
-                instanceId, lastApproverId);
-        }
-        else if (finalStatus == FlowInstanceStatusEnum.Cancelled)
+        // 状态由 S8 服务层管控(VERIFY_APPROVED/VERIFY_REJECTED 时间线已写)。
+        // 这里只补一条流程级审计标记,便于追溯审批实例终态。
+        var label = finalStatus switch
         {
-            // 撤回:状态维持 RESOLVED,不写终态
-            await _rep.UpdateAsync(e);
-            await InsertTimelineAsync(e.Id, "CLOSE_CANCELLED", "处理人撤回关闭申请", null, null,
-                instanceId, lastApproverId);
-        }
-        else
-        {
-            e.Status = "IN_PROGRESS";
-            await _rep.UpdateAsync(e);
-            await InsertTimelineAsync(e.Id, "CLOSE_REJECTED", "关闭被驳回", "RESOLVED", "IN_PROGRESS",
-                instanceId, lastApproverId);
-        }
+            FlowInstanceStatusEnum.Approved => "复检审批流通过",
+            FlowInstanceStatusEnum.Rejected => "复检审批流拒绝",
+            FlowInstanceStatusEnum.Cancelled => "复检审批流撤回",
+            _ => "复检审批流结束",
+        };
+        await InsertTimelineAsync(e.Id, "VERIFY_FLOW_END", label, instanceId, lastApproverId);
     }
 
     public async Task<Dictionary<string, object>> GetBizData(long bizId)
@@ -76,7 +66,7 @@ public class ExceptionClosureBizHandler : IFlowBizHandler, ITransient
     }
 
     private async Task InsertTimelineAsync(long exceptionId, string code, string label,
-        string? from, string? to, long? instanceId, long? approverId)
+        long? instanceId, long? approverId)
     {
         string? remark = null;
         if (instanceId.HasValue && approverId.HasValue)
@@ -91,8 +81,8 @@ public class ExceptionClosureBizHandler : IFlowBizHandler, ITransient
             ExceptionId = exceptionId,
             ActionCode = code,
             ActionLabel = label,
-            FromStatus = from,
-            ToStatus = to,
+            FromStatus = null,
+            ToStatus = null,
             OperatorId = approverId,
             ActionRemark = remark,
             CreatedAt = DateTime.Now

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -81,6 +81,7 @@ public class S8ManualReportService : ITransient
             {
                 BizType = "EXCEPTION_REPORT",
                 BizId = entity.Id,
+                BizNo = entity.ExceptionCode,
                 Title = $"异常提报 - {entity.ExceptionCode}",
                 Comment = entity.SourceType == "AUTO_WATCH" ? "自动监控触发" : "主动提报触发",
                 BizData = new Dictionary<string, object>

+ 215 - 27
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs

@@ -1,7 +1,9 @@
 using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.ApprovalFlow;
 using Admin.NET.Plugin.ApprovalFlow.Service;
+using Microsoft.Extensions.Logging;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -10,21 +12,30 @@ public class S8TaskFlowService : ITransient
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
     private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
     private readonly SqlSugarRepository<AdoS0EmployeeMaster> _employeeRep;
+    private readonly SqlSugarRepository<ApprovalFlowInstance> _flowInstanceRep;
+    private readonly SqlSugarRepository<ApprovalFlowTask> _flowTaskRep;
     private readonly FlowEngineService _flowEngine;
     private readonly UserManager _userManager;
+    private readonly ILogger<S8TaskFlowService> _logger;
 
     public S8TaskFlowService(
         SqlSugarRepository<AdoS8Exception> rep,
         SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
         SqlSugarRepository<AdoS0EmployeeMaster> employeeRep,
+        SqlSugarRepository<ApprovalFlowInstance> flowInstanceRep,
+        SqlSugarRepository<ApprovalFlowTask> flowTaskRep,
         FlowEngineService flowEngine,
-        UserManager userManager)
+        UserManager userManager,
+        ILogger<S8TaskFlowService> logger)
     {
         _rep = rep;
         _timelineRep = timelineRep;
         _employeeRep = employeeRep;
+        _flowInstanceRep = flowInstanceRep;
+        _flowTaskRep = flowTaskRep;
         _flowEngine = flowEngine;
         _userManager = userManager;
+        _logger = logger;
     }
 
     public async Task<AdoS8Exception> ClaimAsync(long id, long tenantId, long factoryId, long assigneeId, string? remark)
@@ -46,6 +57,7 @@ public class S8TaskFlowService : ITransient
             await InsertTimelineAsync(e.Id, "CLAIM", "认领", fromStatus, "ASSIGNED", assigneeId, null, remark);
         }, ex => throw ex);
 
+        // 注:认领仅承接异常,不视为审批流完成。审批流的"通过"在"开始处理"那一步触发。
         return e;
     }
 
@@ -72,9 +84,54 @@ public class S8TaskFlowService : ITransient
             await InsertTimelineAsync(e.Id, "TRANSFER", "转派", e.Status, e.Status, newAssigneeId, null, remark);
         }, ex => throw ex);
 
+        // 双线合一:S8 转派 = TB001 任务转办给新处理人。
+        await TryTransferIntakeOnTransferAsync(e.Id, newAssigneeId, remark);
+
         return e;
     }
 
+    private async Task TryTransferIntakeOnTransferAsync(long exceptionId, long newAssigneeRecId, string? remark)
+    {
+        try
+        {
+            var instance = await _flowInstanceRep.AsQueryable()
+                .Where(x => x.BizType == "EXCEPTION_REPORT"
+                            && x.BizId == exceptionId
+                            && x.Status == FlowInstanceStatusEnum.Running)
+                .FirstAsync();
+            if (instance == null) return;
+
+            var currentUserId = _userManager.UserId;
+            var task = await _flowTaskRep.AsQueryable()
+                .Where(x => x.InstanceId == instance.Id
+                            && x.AssigneeId == currentUserId
+                            && x.Status == FlowTaskStatusEnum.Pending)
+                .FirstAsync();
+            if (task == null) return;
+
+            // newAssigneeRecId 是 EmployeeMaster.RecID;FlowEngine.Transfer 要 SysUser.UserId。
+            var targetSysUserId = await _employeeRep.AsQueryable().ClearFilter()
+                .Where(x => x.Id == newAssigneeRecId && x.SysUserId != null)
+                .Select(x => x.SysUserId)
+                .FirstAsync();
+            if (targetSysUserId == null || targetSysUserId == 0)
+            {
+                _logger.LogWarning(
+                    "S8 转派联动审批流跳过:员工 {RecId} 未绑定 SysUser,TB001 任务保留原 assignee",
+                    newAssigneeRecId);
+                return;
+            }
+
+            await _flowEngine.Transfer(task.Id, targetSysUserId.Value, remark ?? "S8 转派(双线合一自动转办)");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "S8 转派时自动转办 TB001 任务失败 exceptionId={Id} newAssigneeRecId={AssigneeId}",
+                exceptionId, newAssigneeRecId);
+        }
+    }
+
     public async Task<AdoS8Exception> StartProgressAsync(long id, long tenantId, long factoryId, string? remark)
     {
         var currentUserId = GetCurrentUserId();
@@ -92,9 +149,41 @@ public class S8TaskFlowService : ITransient
             await InsertTimelineAsync(e.Id, "START_PROGRESS", "开始处理", fromStatus, "IN_PROGRESS", currentUserId, null, remark);
         }, ex => throw ex);
 
+        // 双线合一:开始处理 = TB001 异常提报审批通过。
+        // 当前用户必须是 TB001 task 的 AssigneeId,FlowEngine 强校验。
+        await TryApproveIntakeOnStartProgressAsync(e.Id, currentUserId);
+
         return e;
     }
 
+    private async Task TryApproveIntakeOnStartProgressAsync(long exceptionId, long currentUserId)
+    {
+        try
+        {
+            var instance = await _flowInstanceRep.AsQueryable()
+                .Where(x => x.BizType == "EXCEPTION_REPORT"
+                            && x.BizId == exceptionId
+                            && x.Status == FlowInstanceStatusEnum.Running)
+                .FirstAsync();
+            if (instance == null) return;
+
+            var task = await _flowTaskRep.AsQueryable()
+                .Where(x => x.InstanceId == instance.Id
+                            && x.AssigneeId == currentUserId
+                            && x.Status == FlowTaskStatusEnum.Pending)
+                .FirstAsync();
+            if (task == null) return;
+
+            await _flowEngine.Approve(task.Id, "S8 已开始处理(双线合一自动同意)");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "S8 开始处理时自动同意 TB001 任务失败 exceptionId={Id} userId={UserId}",
+                exceptionId, currentUserId);
+        }
+    }
+
     public async Task<AdoS8Exception> UpgradeAsync(long id, long tenantId, long factoryId, string? remark)
     {
         var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
@@ -139,33 +228,39 @@ public class S8TaskFlowService : ITransient
             await InsertTimelineAsync(e.Id, "REJECT", "驳回", from, "REJECTED", null, null, remark);
         }, ex => throw ex);
 
+        // 双线合一:S8 驳回 = TB001 流程整体拒绝(取消所有 pending 任务、Instance 终止)。
+        await TryRejectIntakeOnRejectAsync(e.Id, remark);
+
         return e;
     }
 
-    public async Task<AdoS8Exception> CloseAsync(long id, long tenantId, long factoryId, string? remark)
+    private async Task TryRejectIntakeOnRejectAsync(long exceptionId, string? remark)
     {
-        var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
-
-        if (e.ActiveFlowInstanceId.HasValue)
-            throw new S8BizException("该异常已有进行中的审批流程,请等待审批完成");
-
-        if (!S8StatusRules.IsAllowedTransition(e.Status, "CLOSED"))
-            throw new S8BizException($"状态 {e.Status} 不可关闭");
-
-        await _flowEngine.StartFlow(new StartFlowInput
+        try
         {
-            BizType = "EXCEPTION_CLOSURE",
-            BizId = e.Id,
-            Title = $"异常关闭确认 - {e.ExceptionCode}",
-            Comment = remark,
-            BizData = new Dictionary<string, object>
-            {
-                ["sceneCode"] = e.SceneCode,
-            }
-        });
-
-        // 状态和时间线由 ExceptionClosureBizHandler.OnFlowStarted 回调更新
-        return await LoadAsync(id, tenantId, factoryId) ?? e;
+            var instance = await _flowInstanceRep.AsQueryable()
+                .Where(x => x.BizType == "EXCEPTION_REPORT"
+                            && x.BizId == exceptionId
+                            && x.Status == FlowInstanceStatusEnum.Running)
+                .FirstAsync();
+            if (instance == null) return;
+
+            var currentUserId = _userManager.UserId;
+            var task = await _flowTaskRep.AsQueryable()
+                .Where(x => x.InstanceId == instance.Id
+                            && x.AssigneeId == currentUserId
+                            && x.Status == FlowTaskStatusEnum.Pending)
+                .FirstAsync();
+            if (task == null) return;
+
+            await _flowEngine.Reject(task.Id, remark ?? "S8 已驳回(双线合一自动拒绝)");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "S8 驳回时自动拒绝 TB001 流程失败 exceptionId={Id}",
+                exceptionId);
+        }
     }
 
     public async Task<AdoS8Exception> SubmitVerificationAsync(
@@ -194,7 +289,37 @@ public class S8TaskFlowService : ITransient
                 currentUserId, null, remark);
         }, ex => throw ex);
 
-        return e;
+        // 双线合一:提交复检 = 启动 EXCEPTION_CLOSURE 流程,指派检验人。
+        // 该流程定义复用,作为复检/关闭确认通用审批载体;handler 只做 ActiveFlowInstanceId 维护。
+        await TryStartVerificationFlowAsync(e, verifierId, remark);
+
+        return await LoadAsync(id, tenantId, factoryId) ?? e;
+    }
+
+    private async Task TryStartVerificationFlowAsync(AdoS8Exception e, long verifierRecId, string? remark)
+    {
+        try
+        {
+            await _flowEngine.StartFlow(new StartFlowInput
+            {
+                BizType = "EXCEPTION_CLOSURE",
+                BizId = e.Id,
+                BizNo = e.ExceptionCode,
+                Title = $"异常复检 - {e.ExceptionCode}",
+                Comment = remark,
+                BizData = new Dictionary<string, object>
+                {
+                    ["sceneCode"] = e.SceneCode ?? string.Empty,
+                    ["verifierRecId"] = verifierRecId,
+                }
+            });
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "S8 提交复检时启动 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} verifierRecId={VerifierId}",
+                e.Id, verifierRecId);
+        }
     }
 
     public async Task<AdoS8Exception> ApproveVerificationAsync(
@@ -205,26 +330,58 @@ public class S8TaskFlowService : ITransient
         var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
         await EnsureCurrentUserIsOperatorAsync(e.VerifierId, currentUserId,
             "只有指定检验人才能检验通过(或当前账号未绑定员工主数据)");
-        if (!S8StatusRules.IsAllowedTransition(e.Status, "RESOLVED"))
+        if (!S8StatusRules.IsAllowedTransition(e.Status, "CLOSED"))
             throw new S8BizException($"状态 {e.Status} 不可检验通过");
 
         var from = e.Status;
-        e.Status = "RESOLVED";
+        e.Status = "CLOSED";
         e.VerifiedAt = DateTime.Now;
         e.VerificationResult = "APPROVED";
         e.VerificationRemark = remark;
+        e.ClosedAt = DateTime.Now;
         e.UpdatedAt = DateTime.Now;
 
         await _rep.AsTenant().UseTranAsync(async () =>
         {
             await _rep.UpdateAsync(e);
-            await InsertTimelineAsync(e.Id, "VERIFY_APPROVED", "检验通过", from, "RESOLVED",
+            await InsertTimelineAsync(e.Id, "VERIFY_APPROVED", "检验通过", from, "CLOSED",
                 currentUserId, null, remark);
         }, ex => throw ex);
 
+        // 双线合一:检验通过 = EXCEPTION_CLOSURE 复检流程审批通过。
+        await TryApproveVerificationFlowAsync(e.Id, currentUserId);
+
         return e;
     }
 
+    private async Task TryApproveVerificationFlowAsync(long exceptionId, long currentUserId)
+    {
+        try
+        {
+            var instance = await _flowInstanceRep.AsQueryable()
+                .Where(x => x.BizType == "EXCEPTION_CLOSURE"
+                            && x.BizId == exceptionId
+                            && x.Status == FlowInstanceStatusEnum.Running)
+                .FirstAsync();
+            if (instance == null) return;
+
+            var task = await _flowTaskRep.AsQueryable()
+                .Where(x => x.InstanceId == instance.Id
+                            && x.AssigneeId == currentUserId
+                            && x.Status == FlowTaskStatusEnum.Pending)
+                .FirstAsync();
+            if (task == null) return;
+
+            await _flowEngine.Approve(task.Id, "S8 检验通过(双线合一自动同意)");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "S8 检验通过时自动同意 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} userId={UserId}",
+                exceptionId, currentUserId);
+        }
+    }
+
     public async Task<AdoS8Exception> RejectVerificationAsync(
         long id, long tenantId, long factoryId,
         string remark)
@@ -252,9 +409,40 @@ public class S8TaskFlowService : ITransient
                 currentUserId, null, remark);
         }, ex => throw ex);
 
+        // 双线合一:检验退回 = EXCEPTION_CLOSURE 复检流程整体拒绝。
+        await TryRejectVerificationFlowAsync(e.Id, currentUserId, remark);
+
         return e;
     }
 
+    private async Task TryRejectVerificationFlowAsync(long exceptionId, long currentUserId, string? remark)
+    {
+        try
+        {
+            var instance = await _flowInstanceRep.AsQueryable()
+                .Where(x => x.BizType == "EXCEPTION_CLOSURE"
+                            && x.BizId == exceptionId
+                            && x.Status == FlowInstanceStatusEnum.Running)
+                .FirstAsync();
+            if (instance == null) return;
+
+            var task = await _flowTaskRep.AsQueryable()
+                .Where(x => x.InstanceId == instance.Id
+                            && x.AssigneeId == currentUserId
+                            && x.Status == FlowTaskStatusEnum.Pending)
+                .FirstAsync();
+            if (task == null) return;
+
+            await _flowEngine.Reject(task.Id, remark ?? "S8 检验退回(双线合一自动拒绝)");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "S8 检验退回时自动拒绝 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} userId={UserId}",
+                exceptionId, currentUserId);
+        }
+    }
+
     public async Task CommentAsync(long id, string? remark)
     {
         var e = await _rep.GetFirstAsync(x => x.Id == id && !x.IsDeleted)