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

feat: 订单评审不缺料时关闭历史工单 + 前端健壮性增强 + SysJob死锁修复

- 订单评审:资源检查不缺料时自动关闭已有工单(WorkOrdMaster.Status=C,IsActive=0; mes_morder.morder_state=关闭)
- 交期确认:允许无活跃工单的明细行通过确认,不再报错
- DTO: 新增 WorkOrderClosedCount 字段,汇总消息展示关闭计数
- 前端: 评审/交期确认 API 超时增至300s,loadList移入finally确保列表刷新
- 前端: 成功提示展示warnings,duration=5s+showClose
- SysJobService: ClearExpireJobTriggerRecord 拆分两步查询避免同表子查询死锁,增加重试机制

版本: Web 2.4.195 / Server 1.0.194
Pengxy 1 месяц назад
Родитель
Сommit
7460729b1d

+ 162 - 162
Web/package.json

@@ -1,164 +1,164 @@
 {
-  "name": "admin.net",
-  "type": "module",
-  "version": "2.4.194",
-  "packageManager": "pnpm@10.32.1",
-  "lastBuildTime": "2026.03.15",
-  "description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",
-  "author": "zuohuaijun",
-  "license": "MIT",
-  "scripts": {
-    "dev": "vite",
-    "build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite build",
-    "prepare": "node scripts/install-git-hooks.cjs",
-    "lint-fix": "eslint --fix src/",
-    "format": "prettier --write .",
-    "build-api": "cd api_build/ && build.bat",
-    "build-approvalFlow-api": "cd api_build/ && build.bat approvalFlow",
-    "build-dingTalk-api": "cd api_build/ && build.bat dingTalk",
-    "build-goView-api": "cd api_build/ && build.bat goView",
-    "build-all-api": "npm run build-api && npm run build-approvalFlow-api &&  npm run build-dingTalk-api && npm run build-goView-api",
-    "build-api-ps": "cd api_build/ && pwsh -ExecutionPolicy Bypass -File build.ps1",
-    "build-approvalFlow-api-ps": "cd api_build/ && pwsh -ExecutionPolicy Bypass -File build.ps1 approvalFlow",
-    "build-dingTalk-api-ps": "cd api_build/ && pwsh -ExecutionPolicy Bypass -File build.ps1 dingTalk",
-    "build-goView-api-ps": "cd api_build/ && pwsh -ExecutionPolicy Bypass -File build.ps1 goView",
-    "build-all-api-ps": "npm run build-api-ps && npm run build-approvalFlow-api-ps &&  npm run build-dingTalk-api-ps && npm run build-goView-api-ps",
-    "translate": "node scripts/translate.cjs",
-    "test": "vitest",
-    "test:run": "vitest run"
-  },
-  "dependencies": {
-    "@element-plus/icons-vue": "^2.3.2",
-    "@logicflow/core": "^2.2.0",
-    "@logicflow/extension": "^2.2.0",
-    "@logicflow/vue-node-registry": "^1.2.0",
-    "@microsoft/signalr": "^10.0.0",
-    "@vue-office/docx": "^1.6.3",
-    "@vue-office/excel": "^1.7.14",
-    "@vue-office/pdf": "^2.0.10",
-    "@vueuse/core": "^14.2.1",
-    "@wangeditor/editor": "^5.1.23",
-    "@wangeditor/editor-for-vue": "^5.1.12",
-    "animate.css": "^4.1.1",
-    "async-validator": "^4.2.5",
-    "axios": "^1.13.6",
-    "countup.js": "^2.10.0",
-    "cropperjs": "^1.6.2",
-    "echarts": "^6.0.0",
-    "echarts-gl": "^2.0.9",
-    "echarts-wordcloud": "^2.1.0",
-    "element-plus": "^2.13.6",
-    "ezuikit-js": "^8.2.6",
-    "js-cookie": "^3.0.5",
-    "js-table2excel": "^1.1.2",
-    "json-editor-vue": "^0.18.1",
-    "jsplumb": "^2.15.6",
-    "lodash-es": "^4.17.23",
-    "md-editor-v3": "^6.4.0",
-    "mitt": "^3.0.1",
-    "monaco-editor": "^0.55.1",
-    "mqtt": "^5.15.0",
-    "nprogress": "^0.2.0",
-    "pinia": "^3.0.4",
-    "print-js": "^1.6.0",
-    "push.js": "^1.0.12",
-    "qrcodejs2-fixes": "^0.0.2",
-    "qs": "^6.15.0",
-    "relation-graph": "^2.2.11",
-    "screenfull": "^6.0.2",
-    "sm-crypto-v2": "^1.15.1",
-    "sortablejs": "^1.15.7",
-    "splitpanes": "^4.0.4",
-    "vcrontab-3": "^3.3.22",
-    "vform3-builds": "^3.0.10",
-    "vue": "^3.5.30",
-    "vue-clipboard3": "^2.0.0",
-    "vue-demi": "^0.14.10",
-    "vue-draggable-plus": "^0.6.1",
-    "vue-grid-layout": "3.0.0-beta1",
-    "vue-i18n": "^11.3.0",
-    "vue-json-pretty": "^2.6.0",
-    "vue-plugin-hiprint": "^0.0.60",
-    "vue-router": "^4.5.1",
-    "vue-signature-pad": "^3.0.2",
-    "vue3-tree-org": "^4.2.2",
-    "xlsx-js-style": "^1.2.0"
-  },
-  "devDependencies": {
-    "@eslint/eslintrc": "^3.3.5",
-    "@eslint/js": "^10.0.1",
-    "@playwright/test": "^1.59.1",
-    "@plugin-web-update-notification/vite": "^2.0.2",
-    "@rollup/pluginutils": "^5.3.0",
-    "@types/lodash-es": "^4.17.12",
-    "@types/node": "^22.19.15",
-    "@types/nprogress": "^0.2.3",
-    "@types/sortablejs": "^1.15.9",
-    "@typescript-eslint/eslint-plugin": "^8.57.0",
-    "@typescript-eslint/parser": "^8.57.0",
-    "@vitejs/plugin-vue": "^6.0.5",
-    "@vitejs/plugin-vue-jsx": "^5.1.5",
-    "@vue/compiler-sfc": "^3.5.30",
-    "cli-progress": "^3.12.0",
-    "code-inspector-plugin": "^1.4.4",
-    "colors": "^1.4.0",
-    "dotenv": "^17.3.1",
-    "esbuild": "^0.27.4",
-    "eslint": "^10.0.3",
-    "eslint-plugin-vue": "^10.8.0",
-    "globals": "^17.4.0",
-    "jsdom": "^26.1.0",
-    "less": "^4.6.4",
-    "prettier": "^3.8.1",
-    "rollup-plugin-visualizer": "^7.0.1",
-    "sass": "^1.98.0",
-    "terser": "^5.46.0",
-    "typescript": "^5.9.3",
-    "vite": "^8.0.0",
-    "vite-auto-i18n-plugin": "^1.1.16",
-    "vite-plugin-cdn-import": "^1.0.1",
-    "vite-plugin-compression2": "^2.5.1",
-    "vite-plugin-vue-setup-extend": "^0.4.0",
-    "vitest": "^3.2.4",
-    "vue-eslint-parser": "^10.4.0",
-    "vue-tsc": "^3.2.6"
-  },
-  "pnpm": {
-    "onlyBuiltDependencies": [
-      "@vue-office/docx",
-      "@vue-office/excel",
-      "@vue-office/pdf"
-    ],
-    "ignoredBuiltDependencies": [
-      "@parcel/watcher",
-      "core-js",
-      "es5-ext",
-      "esbuild",
-      "json-editor-vue",
-      "less",
-      "vue-demi"
-    ],
-    "overrides": {
-      "rollup": "4.43.0"
-    }
-  },
-  "browserslist": [
-    "> 1%",
-    "last 2 versions",
-    "not dead"
-  ],
-  "engines": {
-    "node": ">=18.0.0",
-    "npm": ">= 7.0.0"
-  },
-  "keywords": [
-    "admin.net",
-    "vue",
-    "vue3",
-    "vuejs/vue-next",
-    "element-ui",
-    "element-plus",
-    "vue-next-admin",
-    "next-admin"
-  ]
+	"name": "admin.net",
+	"type": "module",
+	"version": "2.4.195",
+	"packageManager": "pnpm@10.32.1",
+	"lastBuildTime": "2026.03.15",
+	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",
+	"author": "zuohuaijun",
+	"license": "MIT",
+	"scripts": {
+		"dev": "vite",
+		"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite build",
+		"prepare": "node scripts/install-git-hooks.cjs",
+		"lint-fix": "eslint --fix src/",
+		"format": "prettier --write .",
+		"build-api": "cd api_build/ && build.bat",
+		"build-approvalFlow-api": "cd api_build/ && build.bat approvalFlow",
+		"build-dingTalk-api": "cd api_build/ && build.bat dingTalk",
+		"build-goView-api": "cd api_build/ && build.bat goView",
+		"build-all-api": "npm run build-api && npm run build-approvalFlow-api &&  npm run build-dingTalk-api && npm run build-goView-api",
+		"build-api-ps": "cd api_build/ && pwsh -ExecutionPolicy Bypass -File build.ps1",
+		"build-approvalFlow-api-ps": "cd api_build/ && pwsh -ExecutionPolicy Bypass -File build.ps1 approvalFlow",
+		"build-dingTalk-api-ps": "cd api_build/ && pwsh -ExecutionPolicy Bypass -File build.ps1 dingTalk",
+		"build-goView-api-ps": "cd api_build/ && pwsh -ExecutionPolicy Bypass -File build.ps1 goView",
+		"build-all-api-ps": "npm run build-api-ps && npm run build-approvalFlow-api-ps &&  npm run build-dingTalk-api-ps && npm run build-goView-api-ps",
+		"translate": "node scripts/translate.cjs",
+		"test": "vitest",
+		"test:run": "vitest run"
+	},
+	"dependencies": {
+		"@element-plus/icons-vue": "^2.3.2",
+		"@logicflow/core": "^2.2.0",
+		"@logicflow/extension": "^2.2.0",
+		"@logicflow/vue-node-registry": "^1.2.0",
+		"@microsoft/signalr": "^10.0.0",
+		"@vue-office/docx": "^1.6.3",
+		"@vue-office/excel": "^1.7.14",
+		"@vue-office/pdf": "^2.0.10",
+		"@vueuse/core": "^14.2.1",
+		"@wangeditor/editor": "^5.1.23",
+		"@wangeditor/editor-for-vue": "^5.1.12",
+		"animate.css": "^4.1.1",
+		"async-validator": "^4.2.5",
+		"axios": "^1.13.6",
+		"countup.js": "^2.10.0",
+		"cropperjs": "^1.6.2",
+		"echarts": "^6.0.0",
+		"echarts-gl": "^2.0.9",
+		"echarts-wordcloud": "^2.1.0",
+		"element-plus": "^2.13.6",
+		"ezuikit-js": "^8.2.6",
+		"js-cookie": "^3.0.5",
+		"js-table2excel": "^1.1.2",
+		"json-editor-vue": "^0.18.1",
+		"jsplumb": "^2.15.6",
+		"lodash-es": "^4.17.23",
+		"md-editor-v3": "^6.4.0",
+		"mitt": "^3.0.1",
+		"monaco-editor": "^0.55.1",
+		"mqtt": "^5.15.0",
+		"nprogress": "^0.2.0",
+		"pinia": "^3.0.4",
+		"print-js": "^1.6.0",
+		"push.js": "^1.0.12",
+		"qrcodejs2-fixes": "^0.0.2",
+		"qs": "^6.15.0",
+		"relation-graph": "^2.2.11",
+		"screenfull": "^6.0.2",
+		"sm-crypto-v2": "^1.15.1",
+		"sortablejs": "^1.15.7",
+		"splitpanes": "^4.0.4",
+		"vcrontab-3": "^3.3.22",
+		"vform3-builds": "^3.0.10",
+		"vue": "^3.5.30",
+		"vue-clipboard3": "^2.0.0",
+		"vue-demi": "^0.14.10",
+		"vue-draggable-plus": "^0.6.1",
+		"vue-grid-layout": "3.0.0-beta1",
+		"vue-i18n": "^11.3.0",
+		"vue-json-pretty": "^2.6.0",
+		"vue-plugin-hiprint": "^0.0.60",
+		"vue-router": "^4.5.1",
+		"vue-signature-pad": "^3.0.2",
+		"vue3-tree-org": "^4.2.2",
+		"xlsx-js-style": "^1.2.0"
+	},
+	"devDependencies": {
+		"@eslint/eslintrc": "^3.3.5",
+		"@eslint/js": "^10.0.1",
+		"@playwright/test": "^1.59.1",
+		"@plugin-web-update-notification/vite": "^2.0.2",
+		"@rollup/pluginutils": "^5.3.0",
+		"@types/lodash-es": "^4.17.12",
+		"@types/node": "^22.19.15",
+		"@types/nprogress": "^0.2.3",
+		"@types/sortablejs": "^1.15.9",
+		"@typescript-eslint/eslint-plugin": "^8.57.0",
+		"@typescript-eslint/parser": "^8.57.0",
+		"@vitejs/plugin-vue": "^6.0.5",
+		"@vitejs/plugin-vue-jsx": "^5.1.5",
+		"@vue/compiler-sfc": "^3.5.30",
+		"cli-progress": "^3.12.0",
+		"code-inspector-plugin": "^1.4.4",
+		"colors": "^1.4.0",
+		"dotenv": "^17.3.1",
+		"esbuild": "^0.27.4",
+		"eslint": "^10.0.3",
+		"eslint-plugin-vue": "^10.8.0",
+		"globals": "^17.4.0",
+		"jsdom": "^26.1.0",
+		"less": "^4.6.4",
+		"prettier": "^3.8.1",
+		"rollup-plugin-visualizer": "^7.0.1",
+		"sass": "^1.98.0",
+		"terser": "^5.46.0",
+		"typescript": "^5.9.3",
+		"vite": "^8.0.0",
+		"vite-auto-i18n-plugin": "^1.1.16",
+		"vite-plugin-cdn-import": "^1.0.1",
+		"vite-plugin-compression2": "^2.5.1",
+		"vite-plugin-vue-setup-extend": "^0.4.0",
+		"vitest": "^3.2.4",
+		"vue-eslint-parser": "^10.4.0",
+		"vue-tsc": "^3.2.6"
+	},
+	"pnpm": {
+		"onlyBuiltDependencies": [
+			"@vue-office/docx",
+			"@vue-office/excel",
+			"@vue-office/pdf"
+		],
+		"ignoredBuiltDependencies": [
+			"@parcel/watcher",
+			"core-js",
+			"es5-ext",
+			"esbuild",
+			"json-editor-vue",
+			"less",
+			"vue-demi"
+		],
+		"overrides": {
+			"rollup": "4.43.0"
+		}
+	},
+	"browserslist": [
+		"> 1%",
+		"last 2 versions",
+		"not dead"
+	],
+	"engines": {
+		"node": ">=18.0.0",
+		"npm": ">= 7.0.0"
+	},
+	"keywords": [
+		"admin.net",
+		"vue",
+		"vue3",
+		"vuejs/vue-next",
+		"element-ui",
+		"element-plus",
+		"vue-next-admin",
+		"next-admin"
+	]
 }

+ 2 - 2
Web/src/views/aidop/api/seOrderReview.ts

@@ -124,14 +124,14 @@ export function saveSeOrder(body: SeOrderUpsert) {
 /** 订单评审(新系统后端:资源检查 + 工单生成) */
 export function reviewSeOrder(ids: number[]) {
 	return service
-		.post<SeOrderReviewExecuteResult>('/api/Order/seorder/review', { ids })
+		.post<SeOrderReviewExecuteResult>('/api/Order/seorder/review', { ids }, { timeout: 300000 })
 		.then((r) => r.data);
 }
 
 /** 交期确认(新系统后端) */
 export function confirmSeOrderDelivery(ids: number[]) {
 	return service
-		.post<SeOrderReviewExecuteResult>('/api/Order/seorder/confirm-delivery', { ids })
+		.post<SeOrderReviewExecuteResult>('/api/Order/seorder/confirm-delivery', { ids }, { timeout: 300000 })
 		.then((r) => r.data);
 }
 

+ 8 - 4
Web/src/views/aidop/business/orderList.vue

@@ -250,12 +250,14 @@ async function onReview() {
 	reviewing.value = true;
 	try {
 		const data = await reviewSeOrder(selectedIds.value);
-		ElMessage.success(data?.message || '评审成功');
-		await loadList();
+		const msg = data?.message || '评审成功';
+		const warns = data?.warnings?.length ? `\n注意:${data.warnings.join(';')}` : '';
+		ElMessage({ message: msg + warns, type: 'success', duration: 5000, showClose: true });
 	} catch (e: any) {
 		ElMessage.error(e?.message || '评审失败');
 	} finally {
 		reviewing.value = false;
+		await loadList();
 	}
 }
 
@@ -264,12 +266,14 @@ async function onConfirmDelivery() {
 	confirming.value = true;
 	try {
 		const data = await confirmSeOrderDelivery(selectedIds.value);
-		ElMessage.success(data?.message || '交期确认成功');
-		await loadList();
+		const msg = data?.message || '交期确认成功';
+		const warns = data?.warnings?.length ? `\n注意:${data.warnings.join(';')}` : '';
+		ElMessage({ message: msg + warns, type: 'success', duration: 5000, showClose: true });
 	} catch (e: any) {
 		ElMessage.error(e?.message || '交期确认失败');
 	} finally {
 		confirming.value = false;
+		await loadList();
 	}
 }
 

+ 32 - 10
server/Admin.NET.Core/Service/Job/SysJobService.cs

@@ -384,15 +384,37 @@ public class SysJobService : IDynamicApiController, ITransient
     [DisplayName("清空过期的作业触发器运行记录")]
     public async Task ClearExpireJobTriggerRecord(SysJobTriggerRecord input)
     {
-        int keepRecords = 30;//保留记录条数
-        // 使用CopyNew()创建新的数据库连接实例,避免连接冲突
-        var db = _sysJobTriggerRecordRep.AsSugarClient().CopyNew();
-        await db.Deleteable<SysJobTriggerRecord>().In(it => it.Id,
-            db.Queryable<SysJobTriggerRecord>()
-                .Skip(keepRecords)
-                .OrderByDescending(it => it.LastRunTime)
-                .Where(u => u.JobId == input.JobId && u.TriggerId == input.TriggerId)
-                .Select(it => it.Id) //注意Select不要ToList(), ToList就2次查询了
-        ).ExecuteCommandAsync();
+        const int keepRecords = 30;
+        const int maxRetries = 3;
+
+        for (var attempt = 0; attempt < maxRetries; attempt++)
+        {
+            try
+            {
+                // Step 1: 只读查询待删除的 Id 列表(使用独立连接,避免同表读写锁冲突)
+                var db = _sysJobTriggerRecordRep.AsSugarClient().CopyNew();
+                var idsToDelete = await db.Queryable<SysJobTriggerRecord>()
+                    .Where(u => u.JobId == input.JobId && u.TriggerId == input.TriggerId)
+                    .OrderByDescending(it => it.LastRunTime)
+                    .Skip(keepRecords)
+                    .Take(100) // 限制单次删除量,降低锁争用
+                    .Select(it => it.Id)
+                    .ToListAsync();
+
+                if (idsToDelete.Count == 0) return;
+
+                // Step 2: 按 Id 列表删除(不再包含子查询,避免 gap lock 死锁)
+                await db.Deleteable<SysJobTriggerRecord>()
+                    .Where(it => idsToDelete.Contains(it.Id))
+                    .ExecuteCommandAsync();
+
+                return; // 成功,退出
+            }
+            catch (Exception ex) when (attempt < maxRetries - 1 && ex.Message.Contains("Deadlock", StringComparison.OrdinalIgnoreCase))
+            {
+                // MySQL 死锁:等待随机退避后重试
+                await Task.Delay(Random.Shared.Next(50, 200));
+            }
+        }
     }
 }

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.193</AssemblyVersion>
-    <FileVersion>1.0.193</FileVersion>
-    <Version>1.0.193</Version>
+    <AssemblyVersion>1.0.194</AssemblyVersion>
+    <FileVersion>1.0.194</FileVersion>
+    <Version>1.0.194</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/Dto/OrderReviewExecutionDto.cs

@@ -22,6 +22,7 @@ public class SeOrderReviewExecuteResult
     public int EntryCount { get; set; }
     public int WorkOrderCreatedCount { get; set; }
     public int WorkOrderUpdatedCount { get; set; }
+    public int WorkOrderClosedCount { get; set; }
     public int ResourceCheckCount { get; set; }
     public int ResourceCheckLineCount { get; set; }
     public List<string> WorkOrders { get; set; } = new();

+ 37 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderResourceCheckService.cs

@@ -29,6 +29,43 @@ public class OrderResourceCheckService : ITransient
         return await _writer.WriteAsync(order, entry, workOrd, morderId, lines, account, DateTime.Now);
     }
 
+    /// <summary>仅运行资源检查计算,不写库、不依赖工单。返回计算结果与 BOM 展开明细。</summary>
+    public async Task<(OrderResourceCheckResult Result, List<ResourceCheckBomLine> Lines)> CheckOnlyAsync(
+        OrderWorkOrderGenerationService.OrderHeader order,
+        OrderWorkOrderGenerationService.OrderEntryLine entry,
+        List<string> warnings)
+    {
+        var lines = await _calculator.BuildLinesAsync(order, entry, warnings);
+        var activeLines = lines.Where(x => x.IsUse == 1).ToList();
+        var needTime = entry.SysCapacityDate ?? entry.PlanDate ?? DateTime.Today;
+        var hasShortage = activeLines.Any(x => x.LackQty > 0);
+        var kittingTime = activeLines
+            .Where(x => x.LackQty > 0)
+            .Select(x => x.KittingTime)
+            .DefaultIfEmpty(needTime)
+            .Max();
+
+        var result = new OrderResourceCheckResult
+        {
+            LineCount = activeLines.Count,
+            HasShortage = hasShortage,
+            KittingTime = kittingTime,
+        };
+        return (result, lines);
+    }
+
+    /// <summary>将已有计算结果写入 b_examine_result / b_bom_child_examine(需先创建工单)。</summary>
+    public async Task<OrderResourceCheckResult> WriteResultAsync(
+        OrderWorkOrderGenerationService.OrderHeader order,
+        OrderWorkOrderGenerationService.OrderEntryLine entry,
+        string workOrd,
+        List<ResourceCheckBomLine> lines,
+        string account)
+    {
+        var morderId = await ResolveMorderIdAsync(workOrd, entry.TenantId);
+        return await _writer.WriteAsync(order, entry, workOrd, morderId, lines, account, DateTime.Now);
+    }
+
     private async Task<long?> ResolveMorderIdAsync(string workOrd, long tenantId)
     {
         var id = await _db.Ado.GetLongAsync(

+ 94 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderReviewOrchestrationService.cs

@@ -111,6 +111,7 @@ public class OrderReviewOrchestrationService : ITransient
                 aggregate.EntryCount += perOrder.EntryCount;
                 aggregate.WorkOrderCreatedCount += perOrder.WorkOrderCreatedCount;
                 aggregate.WorkOrderUpdatedCount += perOrder.WorkOrderUpdatedCount;
+                aggregate.WorkOrderClosedCount += perOrder.WorkOrderClosedCount;
                 aggregate.ResourceCheckCount += perOrder.ResourceCheckCount;
                 aggregate.ResourceCheckLineCount += perOrder.ResourceCheckLineCount;
                 aggregate.WorkOrders.AddRange(perOrder.WorkOrders);
@@ -204,9 +205,11 @@ public class OrderReviewOrchestrationService : ITransient
         List<string> warnings,
         string account)
     {
-        var entries = await LoadReviewableEntriesAsync(order.Id, order.TenantId, ["1", "0"]);
+        var entries = await LoadReviewableEntriesAsync(order.Id, order.TenantId, ["1", "0", "2"]);
         if (entries.Count == 0)
-            throw Oops.Oh($"订单 {order.BillNo} 没有可评审的明细行(须为新建或再评审状态)");
+            throw Oops.Oh($"订单 {order.BillNo} 没有可评审的明细行(须为新建,评审,再评审状态)");
+
+        var reviewedEntryIds = new List<long>();
 
         foreach (var entry in entries)
         {
@@ -214,21 +217,39 @@ public class OrderReviewOrchestrationService : ITransient
             if (entry.PlanDate is null)
                 throw Oops.Oh($"订单行 {entry.EntrySeq} 缺少客户要求交期(plan_date)");
 
-            var wo = await _workOrderGen.CreateOrUpdateForEntryAsync(order, entry, account, warnings);
-            if (wo.Created) result.WorkOrderCreatedCount++;
-            else result.WorkOrderUpdatedCount++;
-            result.WorkOrders.Add(wo.WorkOrd);
-
-            var check = await _resourceCheck.RunForEntryAsync(order, entry, wo.WorkOrd, account, warnings);
+            // 1. 先做资源检查(纯 BOM 展开 + 库存计算,不依赖工单)
+            var (check, lines) = await _resourceCheck.CheckOnlyAsync(order, entry, warnings);
             result.ResourceCheckCount++;
             result.ResourceCheckLineCount += check.LineCount;
+
+            // 2. 有缺料 → 才生成工单并写入资源检查结果;库存可满足则跳过工单
             if (check.HasShortage)
+            {
+                var wo = await _workOrderGen.CreateOrUpdateForEntryAsync(order, entry, account, warnings);
+                if (wo.Created) result.WorkOrderCreatedCount++;
+                else result.WorkOrderUpdatedCount++;
+                result.WorkOrders.Add(wo.WorkOrd);
+
+                await _resourceCheck.WriteResultAsync(order, entry, wo.WorkOrd, lines, account);
                 warnings.Add($"订单行 {entry.EntrySeq} 存在缺料(工单 {wo.WorkOrd})");
+            }
+            else
+            {
+                // 3. 不缺料 → 若之前已生成工单则关闭(Status='C', IsActive=0)
+                var closedCount = await CloseExistingWorkOrdersAsync(entry.TenantId, entry.Id, account);
+                if (closedCount > 0)
+                {
+                    result.WorkOrderClosedCount += closedCount;
+                    warnings.Add($"订单行 {entry.EntrySeq} 库存可满足,已关闭 {closedCount} 个历史工单");
+                }
+            }
 
-            var capacityDate = entry.SysCapacityDate ?? entry.PlanDate;
-            await UpdateEntryAfterReviewAsync(entry.Id, capacityDate, account);
+            reviewedEntryIds.Add(entry.Id);
         }
 
+        // 3. 所有明细行处理完毕后,批量更新 progress = '2'
+        await UpdateEntriesProgressAsync(reviewedEntryIds, "2", account);
+
         result.EntryCount = entries.Count;
         result.Message = $"订单 {order.BillNo} 评审完成(资源检查 {result.ResourceCheckCount} 条)";
     }
@@ -253,13 +274,16 @@ public class OrderReviewOrchestrationService : ITransient
                 """,
                 new SugarParameter("@TenantId", entry.TenantId),
                 new SugarParameter("@EntryId", entry.Id));
-            if (woCnt == 0)
-                throw Oops.Oh($"订单行 {entry.EntrySeq} 尚未生成工单,无法交期确认");
 
+            // 无活跃工单:可能从未生成(库存可满足)或已关闭 → 仍允许交期确认
             var confirmDate = entry.SysCapacityDate ?? entry.PlanDate;
             await UpdateEntryAfterConfirmAsync(entry.Id, confirmDate, account);
             result.EntryCount++;
-            result.WorkOrders.Add(await LoadWorkOrdForEntryAsync(entry.TenantId, entry.Id));
+
+            if (woCnt > 0)
+                result.WorkOrders.Add(await LoadWorkOrdForEntryAsync(entry.TenantId, entry.Id));
+            else
+                warnings.Add($"订单行 {entry.EntrySeq} 无活跃工单(库存可满足或已关闭),已直接确认交期");
         }
 
         result.WorkOrders = result.WorkOrders.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
@@ -279,6 +303,59 @@ public class OrderReviewOrchestrationService : ITransient
         return rows.FirstOrDefault() ?? string.Empty;
     }
 
+    /// <summary>
+    /// 关闭指定订单行已有的未关闭工单(WorkOrdMaster.Status='C', IsActive=0;mes_morder.morder_state='关闭')。
+    /// 返回被关闭的工单数量。
+    /// </summary>
+    private async Task<int> CloseExistingWorkOrdersAsync(long tenantId, long entryId, string account)
+    {
+        // 查找该订单行下所有未关闭的工单号
+        var openWorkOrds = await _db.Ado.SqlQueryAsync<string>(
+            """
+            SELECT WorkOrd FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId AND BusinessID = @EntryId
+              AND LOWER(TRIM(IFNULL(Status,''))) <> 'c'
+              AND IFNULL(IsActive, 0) = 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@EntryId", entryId));
+
+        if (openWorkOrds.Count == 0) return 0;
+
+        var workOrdList = string.Join(",", openWorkOrds.Select(w => $"'{w}'"));
+        var now = DateTime.Now;
+
+        // 关闭 WorkOrdMaster
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            UPDATE WorkOrdMaster
+            SET Status = 'C', IsActive = 0,
+                UpdateUser = @User, UpdateTime = @Now
+            WHERE tenant_id = @TenantId AND BusinessID = @EntryId
+              AND LOWER(TRIM(IFNULL(Status,''))) <> 'c'
+              AND IFNULL(IsActive, 0) = 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@EntryId", entryId),
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", now));
+
+        // 同步关闭 mes_morder
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            UPDATE mes_morder
+            SET morder_state = '关闭',
+                update_by_name = @User, update_time = @Now
+            WHERE tenant_id = @TenantId AND morder_no IN ({workOrdList})
+              AND IFNULL(morder_state, '') <> '关闭'
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", now));
+
+        return openWorkOrds.Count;
+    }
+
     private static void ValidateEntryForResourceCheck(OrderWorkOrderGenerationService.OrderEntryLine entry)
     {
         if (string.IsNullOrWhiteSpace(entry.ItemNumber))
@@ -387,9 +464,12 @@ public class OrderReviewOrchestrationService : ITransient
         var woPart = r.WorkOrders.Count > 0
             ? $",工单:{string.Join("、", r.WorkOrders.Distinct())}"
             : string.Empty;
+        var closedPart = r.WorkOrderClosedCount > 0
+            ? $"、关闭 {r.WorkOrderClosedCount}"
+            : string.Empty;
         return actionCode switch
         {
-            ActionReview => $"评审完成 {r.OrderCount} 单、{r.EntryCount} 行,新建工单 {r.WorkOrderCreatedCount}、更新 {r.WorkOrderUpdatedCount}、资源检查 {r.ResourceCheckCount} 条{woPart}",
+            ActionReview => $"评审完成 {r.OrderCount} 单、{r.EntryCount} 行,新建工单 {r.WorkOrderCreatedCount}、更新 {r.WorkOrderUpdatedCount}{closedPart}、资源检查 {r.ResourceCheckCount} 条{woPart}",
             ActionConfirm => $"交期确认完成 {r.OrderCount} 单、{r.EntryCount} 行{woPart}",
             _ => r.Message
         };