瀏覽代碼

feat: 资源检查跨工单库存/在途占用递减 + 生产排程自动刷新

- MaterialRequirementCalculator: 跨工单库存/在途占用写入ic_item_stockoccupy/srm_po_occupy表
- OrderResourceCheckService: 订单评审资源检查支持bangId参数
- OrderReviewOrchestrationService: 评审前清理占用记录(bang_id=2)
- ProductionScheduleGenerationService: 排程前清理占用记录(bang_id=1)
- WorkOrderKittingCheckService: 齐套检查透传bangId
- workOrderSchedulingList: 排程成功后自动刷新列表
- 新增迁移脚本: add_bang_id_to_occupy_tables.sql
Pengxy 13 小時之前
父節點
當前提交
568f6f52a4

+ 162 - 162
Web/package.json

@@ -1,164 +1,164 @@
 {
-	"name": "admin.net",
-	"type": "module",
-	"version": "2.4.198",
-	"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.199",
+  "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"
+  ]
 }

+ 1 - 1
Web/src/views/aidop/api/workOrderScheduling.ts

@@ -30,7 +30,7 @@ export interface WorkOrderSchedulingRow {
 /** 生成生产排程计划 */
 export function productionSchedule(domain: string) {
 	return service
-		.post('/api/Production/scheduling/generate', null, { params: { domain } })
+		.post('/api/Production/scheduling/generate', null, { params: { domain }, timeout: 300000 })
 		.then((r) => r.data);
 }
 

+ 1 - 0
Web/src/views/aidop/production/workOrderSchedulingList.vue

@@ -320,6 +320,7 @@ async function onProductionSchedule() {
 	try {
 		await productionSchedule(domain);
 		ElMessage.success('生产排程已调用');
+		await loadList();
 	} catch (e: any) {
 		ElMessage.error(e?.message || '生产排程失败');
 	} finally {

+ 16 - 0
doc/migrations/add_bang_id_to_occupy_tables.sql

@@ -0,0 +1,16 @@
+-- 添加 bang_id 字段到 ic_item_stockoccupy 表(跨工单库存占用追踪)
+ALTER TABLE ic_item_stockoccupy 
+ADD COLUMN bang_id bigint NULL DEFAULT 0 COMMENT '批次计算ID',
+ADD INDEX idx_stockoccupy_bang_id (tenant_id, bang_id, icitem_number);
+
+-- 添加字段到 srm_po_occupy 表(跨工单在途占用追踪)
+-- 注意:srm_po_occupy 可能已有部分字段,请按实际情况执行
+-- 如果 Id 列已存在(自增或业务生成),请删除 ADD COLUMN Id 行
+ALTER TABLE srm_po_occupy 
+ADD COLUMN Id bigint NOT NULL DEFAULT 0 COMMENT '主键',
+ADD COLUMN ItemNumber varchar(80) NULL COMMENT '物料编码',
+ADD COLUMN OccupyQty decimal(23, 10) NULL DEFAULT 0 COMMENT '占用数量',
+ADD COLUMN morder_mo varchar(80) NULL COMMENT '工单编号',
+ADD COLUMN bang_id bigint NULL DEFAULT 0 COMMENT '批次计算ID',
+ADD COLUMN IsDeleted bit(1) NOT NULL DEFAULT 0 COMMENT '删除标识',
+ADD INDEX idx_pooccupy_bang_id (tenant_id, bang_id, ItemNumber);

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

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
     <TargetFrameworks>net10.0</TargetFrameworks>
@@ -10,10 +10,10 @@
     <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
-    <Description>Admin.NET 通用��开�平�</Description>
-    <AssemblyVersion>1.0.201</AssemblyVersion>
-    <FileVersion>1.0.201</FileVersion>
-    <Version>1.0.201</Version>
+    <Description>Admin.NET ͨÓÃȨÏÞ¿ª·¢Æ½Ì¨</Description>
+    <AssemblyVersion>1.0.202</AssemblyVersion>
+    <FileVersion>1.0.202</FileVersion>
+    <Version>1.0.202</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 106 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs

@@ -18,7 +18,8 @@ public class MaterialRequirementCalculator : ITransient
     public async Task<List<ResourceCheckBomLine>> BuildLinesAsync(
         OrderWorkOrderGenerationService.OrderHeader order,
         OrderWorkOrderGenerationService.OrderEntryLine entry,
-        List<string> warnings)
+        List<string> warnings,
+        long bangId = 0)
     {
         var itemNum = entry.ItemNumber!.Trim();
         var orderQty = entry.Qty ?? 0;
@@ -49,7 +50,7 @@ public class MaterialRequirementCalculator : ITransient
         if (bomId is null)
         {
             warnings.Add($"订单行 {entry.EntrySeq}({itemNum})未匹配到 BOM,仅写入成品行资源检查结果");
-            await ApplySupplyAsync(lines, entry.TenantId);
+            await ApplySupplyAsync(lines, entry.TenantId, bangId);
             return lines;
         }
 
@@ -57,7 +58,7 @@ public class MaterialRequirementCalculator : ITransient
         if (children.Count == 0)
         {
             warnings.Add($"订单行 {entry.EntrySeq} BOM {entry.BomNumber ?? bomId.ToString()} 无子件明细");
-            await ApplySupplyAsync(lines, entry.TenantId);
+            await ApplySupplyAsync(lines, entry.TenantId, bangId);
             return lines;
         }
 
@@ -68,7 +69,7 @@ public class MaterialRequirementCalculator : ITransient
                 lines, rootFid, "1", 1, orderQty, child, entry, needTime, warnings, seq, depth: 1);
         }
 
-        await ApplySupplyAsync(lines, entry.TenantId);
+        await ApplySupplyAsync(lines, entry.TenantId, bangId);
         return lines;
     }
 
@@ -251,11 +252,41 @@ public class MaterialRequirementCalculator : ITransient
             new SugarParameter("@TenantId", tenantId));
     }
 
-    private async Task ApplySupplyAsync(List<ResourceCheckBomLine> lines, long tenantId)
+    private async Task ApplySupplyAsync(List<ResourceCheckBomLine> lines, long tenantId, long bangId = 0)
     {
         var stockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
         var transitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
 
+        // 查询本次计算批次(bangId)已记录的库存占用和采购在途占用
+        var occupiedStockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+        var occupiedTransitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+        if (bangId > 0)
+        {
+            var occupiedRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
+                """
+                SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
+                FROM ic_item_stockoccupy
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
+                GROUP BY icitem_number
+                """,
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@BangId", bangId));
+            foreach (var r in occupiedRows)
+                occupiedStockMap[r.ItemNumber] = r.TotalOccupied;
+
+            var occupiedTransitRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
+                """
+                SELECT ItemNumber, IFNULL(SUM(OccupyQty), 0) AS TotalOccupied
+                FROM srm_po_occupy
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
+                GROUP BY ItemNumber
+                """,
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@BangId", bangId));
+            foreach (var r in occupiedTransitRows)
+                occupiedTransitMap[r.ItemNumber] = r.TotalOccupied;
+        }
+
         foreach (var line in lines)
         {
             if (string.IsNullOrWhiteSpace(line.ItemNumber))
@@ -263,13 +294,19 @@ public class MaterialRequirementCalculator : ITransient
 
             if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
             {
-                stock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
+                var rawStock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
+                // 扣减已被同批次其他工单占用的库存
+                var occupied = occupiedStockMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
+                stock = Math.Max(0m, rawStock - occupied);
                 stockMap[line.ItemNumber] = stock;
             }
 
             if (!transitMap.TryGetValue(line.ItemNumber, out var inTransit))
             {
-                inTransit = await QueryOpenPurchaseQtyAsync(line.ItemNumber, tenantId);
+                var rawTransit = await QueryOpenPurchaseQtyAsync(line.ItemNumber, tenantId);
+                // 扣减已被同批次其他工单占用的在途
+                var occupied = occupiedTransitMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
+                inTransit = Math.Max(0m, rawTransit - occupied);
                 transitMap[line.ItemNumber] = inTransit;
             }
 
@@ -289,6 +326,62 @@ public class MaterialRequirementCalculator : ITransient
             var remainingSqty = RoundQty(Math.Max(0m, line.Sqty - consumedFromStock));
             transitMap[line.ItemNumber] = RoundQty(Math.Max(0m, inTransit - remainingSqty));
         }
+
+        // 写入占用记录(库存占用和在途占用)
+        if (bangId > 0)
+        {
+            foreach (var line in lines)
+            {
+                if (string.IsNullOrWhiteSpace(line.ItemNumber) || line.IsUse != 1)
+                    continue;
+                if (line.UseQty > 0)
+                {
+                    var stockUsed = RoundQty(Math.Min(line.StockQty, line.UseQty));
+                    if (stockUsed > 0)
+                    {
+                        await _db.Ado.ExecuteCommandAsync(
+                            """
+                            INSERT INTO ic_item_stockoccupy (
+                                Id, icitem_number, quantity, morder_mo, bang_id,
+                                tenant_id, occupy_time, create_time, update_time
+                            ) VALUES (
+                                @Id, @ItemNumber, @Qty, @WorkOrd, @BangId,
+                                @TenantId, @Now, @Now, @Now
+                            )
+                            """,
+                            new SugarParameter("@Id", YitIdHelper.NextId()),
+                            new SugarParameter("@ItemNumber", line.ItemNumber),
+                            new SugarParameter("@Qty", stockUsed),
+                            new SugarParameter("@WorkOrd", ""),
+                            new SugarParameter("@BangId", bangId),
+                            new SugarParameter("@TenantId", tenantId),
+                            new SugarParameter("@Now", DateTime.Now));
+                    }
+
+                    var transitUsed = RoundQty(Math.Max(0m, line.UseQty - stockUsed));
+                    if (transitUsed > 0)
+                    {
+                        await _db.Ado.ExecuteCommandAsync(
+                            """
+                            INSERT INTO srm_po_occupy (
+                                Id, ItemNumber, OccupyQty, morder_mo, bang_id,
+                                tenant_id, create_time, update_time
+                            ) VALUES (
+                                @Id, @ItemNumber, @Qty, @WorkOrd, @BangId,
+                                @TenantId, @Now, @Now
+                            )
+                            """,
+                            new SugarParameter("@Id", YitIdHelper.NextId()),
+                            new SugarParameter("@ItemNumber", line.ItemNumber),
+                            new SugarParameter("@Qty", transitUsed),
+                            new SugarParameter("@WorkOrd", ""),
+                            new SugarParameter("@BangId", bangId),
+                            new SugarParameter("@TenantId", tenantId),
+                            new SugarParameter("@Now", DateTime.Now));
+                    }
+                }
+            }
+        }
     }
 
     private async Task<decimal> QueryStockQtyAsync(string itemNum, long tenantId)
@@ -371,4 +464,10 @@ public class MaterialRequirementCalculator : ITransient
         public int HaveIcSubs { get; set; }
         public string? SubstituteCode { get; set; }
     }
+
+    private sealed class OccupyRow
+    {
+        public string ItemNumber { get; set; } = string.Empty;
+        public decimal TotalOccupied { get; set; }
+    }
 }

+ 6 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderResourceCheckService.cs

@@ -22,9 +22,10 @@ public class OrderResourceCheckService : ITransient
         OrderWorkOrderGenerationService.OrderEntryLine entry,
         string workOrd,
         string account,
-        List<string> warnings)
+        List<string> warnings,
+        long bangId = 0)
     {
-        var lines = await _calculator.BuildLinesAsync(order, entry, warnings);
+        var lines = await _calculator.BuildLinesAsync(order, entry, warnings, bangId);
         var morderId = await ResolveMorderIdAsync(workOrd, entry.TenantId);
         return await _writer.WriteAsync(order, entry, workOrd, morderId, lines, account, DateTime.Now);
     }
@@ -33,9 +34,10 @@ public class OrderResourceCheckService : ITransient
     public async Task<(OrderResourceCheckResult Result, List<ResourceCheckBomLine> Lines)> CheckOnlyAsync(
         OrderWorkOrderGenerationService.OrderHeader order,
         OrderWorkOrderGenerationService.OrderEntryLine entry,
-        List<string> warnings)
+        List<string> warnings,
+        long bangId = 0)
     {
-        var lines = await _calculator.BuildLinesAsync(order, entry, warnings);
+        var lines = await _calculator.BuildLinesAsync(order, entry, warnings, bangId);
         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);

+ 19 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderReviewOrchestrationService.cs

@@ -12,6 +12,9 @@ public class OrderReviewOrchestrationService : ITransient
     public const string ActionConfirm = "S1_DELIVERY_CONFIRM";
     public const string ActionRefresh = "S1_ORDER_REFRESH_PLAN";
 
+    /// <summary>订单评审资源检查批次ID,用于跨工单库存占用递减。</summary>
+    private const long ReviewBangId = 2;
+
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
     private readonly OrderWorkOrderGenerationService _workOrderGen;
@@ -62,7 +65,7 @@ public class OrderReviewOrchestrationService : ITransient
                 else result.WorkOrderUpdatedCount++;
                 result.WorkOrders.Add(wo.WorkOrd);
 
-                var check = await _resourceCheck.RunForEntryAsync(order, entry, wo.WorkOrd, account, warnings);
+                var check = await _resourceCheck.RunForEntryAsync(order, entry, wo.WorkOrd, account, warnings, ReviewBangId);
                 result.ResourceCheckCount++;
                 result.ResourceCheckLineCount += check.LineCount;
 
@@ -100,6 +103,19 @@ public class OrderReviewOrchestrationService : ITransient
         var allWarnings = new List<string>();
         long? firstLogId = null;
 
+        // 评审/重排前清理旧占用记录,确保跨工单库存递减正确
+        if (actionCode == ActionReview || actionCode == ActionRefresh)
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM ic_item_stockoccupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@BangId", ReviewBangId));
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM srm_po_occupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@BangId", ReviewBangId));
+        }
+
         foreach (var orderId in distinctIds)
         {
             var order = await LoadOrderAsync(orderId, tenantId)
@@ -225,8 +241,8 @@ public class OrderReviewOrchestrationService : ITransient
             if (entry.PlanDate is null)
                 throw Oops.Oh($"订单行 {entry.EntrySeq} 缺少客户要求交期(plan_date)");
 
-            // 1. 先做资源检查(纯 BOM 展开 + 库存计算,不依赖工单)
-            var (check, lines) = await _resourceCheck.CheckOnlyAsync(order, entry, warnings);
+            // 1. 先做资源检查(纯 BOM 展开 + 库存计算,不依赖工单),记录库存占用
+            var (check, lines) = await _resourceCheck.CheckOnlyAsync(order, entry, warnings, ReviewBangId);
             result.ResourceCheckCount++;
             result.ResourceCheckLineCount += check.LineCount;
 

+ 24 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderWorkOrderGenerationService.cs

@@ -75,6 +75,30 @@ public class OrderWorkOrderGenerationService : ITransient
         if (itemCnt == 0)
             throw Oops.Oh($"物料 {itemNum} 不存在于 ItemMaster,订单行 {entry.EntrySeq} 无法评审");
 
+        // 扣减已有成品库存,工单数量 = lack_qty(订单数量 - 在库数量)
+        var stockQty = await _db.Ado.GetDecimalAsync(
+            """
+            SELECT COALESCE(SUM(
+                CASE
+                    WHEN AvailStatusQty IS NOT NULL THEN AvailStatusQty
+                    WHEN QtyOnHand IS NOT NULL THEN QtyOnHand
+                    ELSE 0
+                END
+            ), 0)
+            FROM InvMaster
+            WHERE ItemNum = @ItemNum
+              AND (tenant_id = @TenantId OR @TenantId = 0)
+            """,
+            new SugarParameter("@ItemNum", itemNum),
+            new SugarParameter("@TenantId", entry.TenantId));
+        var lackQty = Math.Max(0m, qty - stockQty);
+        if (stockQty > 0)
+        {
+            warnings.Add($"订单行 {entry.EntrySeq}({itemNum})成品库存 {stockQty},工单数量 {qty} → {lackQty}");
+        }
+        if (lackQty > 0)
+            qty = lackQty;
+
         if (string.IsNullOrWhiteSpace(entry.BomNumber))
             warnings.Add($"订单行 {entry.EntrySeq}({itemNum})无 BOM 编号,工单已生成但未展开物料明细");
 

+ 6 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ResourceCheckResultWriter.cs

@@ -21,7 +21,7 @@ public class ResourceCheckResultWriter : ITransient
         string account,
         DateTime now)
     {
-        await InvalidatePreviousAsync(entry.Id, entry.TenantId, now);
+        await InvalidatePreviousAsync(entry.Id, entry.TenantId, workOrd, now);
 
         var examineId = YitIdHelper.NextId();
         var needQty = entry.Qty ?? 0;
@@ -146,15 +146,17 @@ public class ResourceCheckResultWriter : ITransient
         };
     }
 
-    private async Task InvalidatePreviousAsync(long entryId, long tenantId, DateTime now)
+    private async Task InvalidatePreviousAsync(long entryId, long tenantId, string morderNo, DateTime now)
     {
         var oldIds = await _db.Ado.SqlQueryAsync<long>(
             """
             SELECT Id FROM b_examine_result
-            WHERE sentry_id = @EntryId AND tenant_id = @TenantId AND IsDeleted = 0
+            WHERE tenant_id = @TenantId AND IsDeleted = 0
+              AND (sentry_id = @EntryId OR morder_no = @MorderNo)
             """,
             new SugarParameter("@EntryId", entryId),
-            new SugarParameter("@TenantId", tenantId));
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@MorderNo", morderNo));
 
         if (oldIds.Count == 0)
             return;

+ 141 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ProductionScheduleGenerationService.cs

@@ -1,13 +1,17 @@
 namespace Admin.NET.Plugin.AiDOP.Production;
 
+using Admin.NET.Plugin.AiDOP.WorkOrder;
+
 /// <summary>生产排程生成:为待排工单写入 PeriodSequenceDet(工作日历 + 工作中心冲突避让)。</summary>
 public class ProductionScheduleGenerationService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly WorkOrderKittingCheckService _kittingCheck;
 
-    public ProductionScheduleGenerationService(ISqlSugarClient db)
+    public ProductionScheduleGenerationService(ISqlSugarClient db, WorkOrderKittingCheckService kittingCheck)
     {
         _db = db;
+        _kittingCheck = kittingCheck;
     }
 
     public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account)
@@ -56,9 +60,48 @@ public class ProductionScheduleGenerationService : ITransient
         var scheduledCount = 0;
         var rowCount = 0;
         var skipped = new List<string>();
+        // 按优先级顺序跟踪各物料已占用库存量(ItemNumber → 已占用数量)
+        var consumedStock = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+
+        // 清理旧的占用记录,确保每次排程重新计算
+        const long scheduleBangId = 1;
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM ic_item_stockoccupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@BangId", scheduleBangId));
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM srm_po_occupy WHERE tenant_id = @TenantId AND bang_id = @BangId",
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@BangId", scheduleBangId));
 
         foreach (var wo in workOrders)
         {
+            // 1. 执行齐套检查,刷新资源检查记录(写入 b_examine_result / b_bom_child_examine)
+            try
+            {
+                await _kittingCheck.CheckSingleAsync(tenantId, wo.WorkOrd, account, scheduleBangId);
+            }
+            catch (Exception ex)
+            {
+                skipped.Add($"齐套检查失败[{wo.WorkOrd}]: {ex.Message}");
+            }
+
+            // 2. 刷新齐套数量(LocationStock),扣减已被高优先级工单占用的库存
+            var locationStock = await CalcLocationStockAsync(tenantId, wo.WorkOrd, wo.QtyOrded ?? 0, consumedStock);
+            if (locationStock.HasValue)
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE WorkOrdMaster
+                    SET LocationStock = @Stock, UpdateTime = @Now
+                    WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+                    """,
+                    new SugarParameter("@Stock", locationStock.Value),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@TenantId", tenantId),
+                    new SugarParameter("@WorkOrd", wo.WorkOrd));
+            }
+
             var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd);
             if (routings.Count == 0)
             {
@@ -311,6 +354,103 @@ public class ProductionScheduleGenerationService : ITransient
         public string Message { get; set; } = string.Empty;
     }
 
+    /// <summary>
+    /// 计算工单齐套数量:MIN(可用库存 / qty) 得可生产成品数,超过工单需求量则取工单需求量。
+    /// 若工单领料单已全部发完料,则齐套数量 = 工单需求量。
+    /// consumedStock 跟踪已被高优先级工单占用的库存量,避免重复占用。
+    /// </summary>
+    private async Task<decimal?> CalcLocationStockAsync(
+        long tenantId, string workOrd, decimal qtyOrded,
+        Dictionary<string, decimal> consumedStock)
+    {
+        // 检查领料单是否已全部发完料
+        var allIssued = await _db.Ado.GetIntAsync(
+            """
+            SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
+            FROM NbrMaster nm
+            WHERE nm.tenant_id = @TenantId AND nm.WorkOrd = @WorkOrd
+              AND nm.Type = 'SM' AND IFNULL(nm.IsActive, 0) = 1
+              AND NOT EXISTS (
+                  SELECT 1 FROM NbrDetail nd
+                  WHERE nd.tenant_id = nm.tenant_id AND nd.Nbr = nm.Nbr
+                    AND nd.Type = 'SM' AND IFNULL(nd.IsActive, 0) = 1
+                    AND IFNULL(nd.QtyRec, 0) < IFNULL(nd.QtyOrd, 0)
+              )
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        if (allIssued > 0 && qtyOrded > 0)
+            return qtyOrded;
+
+        // 从最新资源检查获取各物料的 use_qty 和 qty
+        var materials = await _db.Ado.SqlQueryAsync<LocationStockMaterialRow>(
+            """
+            SELECT
+                bce.item_number AS ItemNumber,
+                IFNULL(bce.use_qty, 0) AS UseQty,
+                IFNULL(bce.qty, 0) AS Qty
+            FROM b_examine_result ber
+            INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.is_use = 1
+            WHERE ber.tenant_id = @TenantId
+              AND ber.IsDeleted = 0
+              AND ber.morder_no = @WorkOrd
+              AND ber.Id = (
+                  SELECT br.Id FROM b_examine_result br
+                  WHERE br.tenant_id = @TenantId AND br.morder_no = @WorkOrd AND br.IsDeleted = 0
+                  ORDER BY br.create_time DESC LIMIT 1
+              )
+              AND IFNULL(bce.qty, 0) > 0
+              AND IFNULL(bce.erp_cls, 3) = 3
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        if (materials.Count == 0)
+            return null;
+
+        // MIN(可用库存 / qty) = 扣减已占用后的瓶颈物料可生产成品数
+        decimal? minProducible = null;
+        foreach (var mat in materials)
+        {
+            if (mat.Qty <= 0) continue;
+            var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m;
+            var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed);
+            var producible = availableUseQty / mat.Qty;
+            if (minProducible is null || producible < minProducible)
+                minProducible = producible;
+        }
+
+        if (minProducible is null)
+            return null;
+
+        // 可生产数 > 工单需求量则取工单需求量
+        var result = Math.Ceiling(minProducible.Value);
+        if (qtyOrded > 0 && result > qtyOrded)
+            result = qtyOrded;
+
+        // 记录本工单占用的库存量
+        foreach (var mat in materials)
+        {
+            if (mat.Qty <= 0) continue;
+            var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m;
+            var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed);
+            var consume = Math.Min(availableUseQty, result * mat.Qty);
+            if (!consumedStock.ContainsKey(mat.ItemNumber))
+                consumedStock[mat.ItemNumber] = 0m;
+            consumedStock[mat.ItemNumber] += consume;
+        }
+
+        return result;
+    }
+
+    private sealed class LocationStockMaterialRow
+    {
+        public string ItemNumber { get; set; } = string.Empty;
+        public decimal UseQty { get; set; }
+        public decimal Qty { get; set; }
+    }
+
     private sealed class PendingWorkOrderRow
     {
         public long RecId { get; set; }

+ 9 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderKittingCheckService.cs

@@ -11,15 +11,18 @@ public class WorkOrderKittingCheckService : ITransient
     private readonly ISqlSugarClient _db;
     private readonly MaterialRequirementCalculator _calculator;
     private readonly ResourceCheckResultWriter _writer;
+    private readonly WorkOrderMaterialDetailSyncService _materialDetailSync;
 
     public WorkOrderKittingCheckService(
         ISqlSugarClient db,
         MaterialRequirementCalculator calculator,
-        ResourceCheckResultWriter writer)
+        ResourceCheckResultWriter writer,
+        WorkOrderMaterialDetailSyncService materialDetailSync)
     {
         _db = db;
         _calculator = calculator;
         _writer = writer;
+        _materialDetailSync = materialDetailSync;
     }
 
     /// <summary>批量齐套检查:租户下所有 Status='p'/'r' 的工单逐一重新检查。</summary>
@@ -49,7 +52,7 @@ public class WorkOrderKittingCheckService : ITransient
     /// 单工单齐套检查:以工单为主,重新 BOM 展开 + 库存计算 → 写入检查结果。
     /// BusinessID>0 时从订单明细行加载数据;BusinessID=0 时从工单/生产工单构造参数。
     /// </summary>
-    public async Task<SingleKittingCheckResult> CheckSingleAsync(long tenantId, string workOrd, string account)
+    public async Task<SingleKittingCheckResult> CheckSingleAsync(long tenantId, string workOrd, string account, long bangId = 0)
     {
         var wo = workOrd.Trim();
 
@@ -87,11 +90,14 @@ public class WorkOrderKittingCheckService : ITransient
 
         // 3. 重新执行资源检查(BOM 展开 + 库存/在途缺口计算)
         var warnings = new List<string>();
-        var lines = await _calculator.BuildLinesAsync(order, entry, warnings);
+        var lines = await _calculator.BuildLinesAsync(order, entry, warnings, bangId);
 
         // 4. 将新结果写入 b_examine_result / b_bom_child_examine 并更新 mes_morder
         var checkResult = await _writer.WriteAsync(order, entry, wo, wm.MorderId, lines, account, DateTime.Now);
 
+        // 5. 同步工单物料明细(WorkOrdDetail)
+        await _materialDetailSync.EnsureFromResourceCheckAsync(tenantId, wo, account);
+
         return new SingleKittingCheckResult
         {
             WorkOrd = wo,

+ 86 - 44
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderMaterialDetailSyncService.cs

@@ -12,17 +12,6 @@ public class WorkOrderMaterialDetailSyncService : ITransient
 
     public async Task<int> EnsureFromResourceCheckAsync(long tenantId, string workOrd, string account)
     {
-        var existing = await _db.Ado.GetIntAsync(
-            """
-            SELECT COUNT(*) FROM WorkOrdDetail
-            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1
-            """,
-            new SugarParameter("@TenantId", tenantId),
-            new SugarParameter("@WorkOrd", workOrd));
-
-        if (existing > 0)
-            return 0;
-
         var wo = await LoadWorkOrderAsync(tenantId, workOrd);
         if (wo is null)
             return 0;
@@ -32,34 +21,85 @@ public class WorkOrderMaterialDetailSyncService : ITransient
             return 0;
 
         var now = DateTime.Now;
-        var line = 1;
+
+        // 加载已有明细,按 (ItemNum) 匹配进行 upsert
+        var existingDetails = await _db.Ado.SqlQueryAsync<ExistingDetailRow>(
+            """
+            SELECT RecID, ItemNum, QtyRequired
+            FROM WorkOrdDetail
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+        var existingMap = existingDetails.ToDictionary(x => x.ItemNum, x => x);
+
+        var inserted = 0;
+        var line = existingDetails.Count + 1;
+
         foreach (var c in components)
+        {
+            if (existingMap.TryGetValue(c.ItemNumber, out var ex))
+            {
+                // 已有明细:更新 QtyRequired
+                if (ex.QtyRequired != c.QtyRequired)
+                {
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE WorkOrdDetail
+                        SET QtyRequired = @QtyRequired, UpdateUser = @User, UpdateTime = @Now
+                        WHERE RecID = @RecID
+                        """,
+                        new SugarParameter("@QtyRequired", c.QtyRequired),
+                        new SugarParameter("@User", account),
+                        new SugarParameter("@Now", now),
+                        new SugarParameter("@RecID", ex.RecID));
+                }
+                existingMap.Remove(c.ItemNumber);
+            }
+            else
+            {
+                // 新增明细
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO WorkOrdDetail (
+                        WorkOrdMasterRecID, `Domain`, WorkOrd, LineNum, ItemNum, Op,
+                        Location, QtyRequired, QtyPosted, UM, IsActive, IsConfirm,
+                        CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
+                    ) VALUES (
+                        @MasterRecId, @Domain, @WorkOrd, @LineNum, @ItemNum, @Op,
+                        @Location, @QtyRequired, 0, @UM, 1, 0,
+                        @User, @Now, @User, @Now, @TenantId
+                    )
+                    """,
+                    new SugarParameter("@MasterRecId", wo.RecId),
+                    new SugarParameter("@Domain", wo.Domain ?? tenantId.ToString()),
+                    new SugarParameter("@WorkOrd", workOrd),
+                    new SugarParameter("@LineNum", line),
+                    new SugarParameter("@ItemNum", c.ItemNumber),
+                    new SugarParameter("@Op", c.Op),
+                    new SugarParameter("@Location", c.Location ?? (object)DBNull.Value),
+                    new SugarParameter("@QtyRequired", c.QtyRequired),
+                    new SugarParameter("@UM", c.Unit ?? (object)DBNull.Value),
+                    new SugarParameter("@User", account),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@TenantId", tenantId));
+                inserted++;
+                line++;
+            }
+        }
+
+        // 删除不再有缺料的旧明细
+        foreach (var stale in existingMap.Values)
         {
             await _db.Ado.ExecuteCommandAsync(
                 """
-                INSERT INTO WorkOrdDetail (
-                    WorkOrdMasterRecID, `Domain`, WorkOrd, LineNum, ItemNum, Op,
-                    Location, QtyRequired, QtyPosted, UM, IsActive, IsConfirm,
-                    CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
-                ) VALUES (
-                    @MasterRecId, @Domain, @WorkOrd, @LineNum, @ItemNum, @Op,
-                    @Location, @QtyRequired, 0, @UM, 1, 0,
-                    @User, @Now, @User, @Now, @TenantId
-                )
+                UPDATE WorkOrdDetail
+                SET IsActive = 0, UpdateUser = @User, UpdateTime = @Now
+                WHERE RecID = @RecID
                 """,
-                new SugarParameter("@MasterRecId", wo.RecId),
-                new SugarParameter("@Domain", wo.Domain ?? tenantId.ToString()),
-                new SugarParameter("@WorkOrd", workOrd),
-                new SugarParameter("@LineNum", line),
-                new SugarParameter("@ItemNum", c.ItemNumber),
-                new SugarParameter("@Op", c.Op),
-                new SugarParameter("@Location", c.Location ?? (object)DBNull.Value),
-                new SugarParameter("@QtyRequired", c.QtyRequired),
-                new SugarParameter("@UM", c.Unit ?? (object)DBNull.Value),
                 new SugarParameter("@User", account),
                 new SugarParameter("@Now", now),
-                new SugarParameter("@TenantId", tenantId));
-            line++;
+                new SugarParameter("@RecID", stale.RecID));
         }
 
         return components.Count;
@@ -85,13 +125,10 @@ public class WorkOrderMaterialDetailSyncService : ITransient
             """
             SELECT
                 bce.item_number AS ItemNumber,
-                CASE
-                    WHEN IFNULL(bce.use_qty, 0) > 0 THEN bce.use_qty
-                    ELSE IFNULL(bce.needCount, 0)
-                END AS QtyRequired,
-                IFNULL(bce.unit, im.Um) AS Unit,
-                IFNULL(im.Location, '') AS Location,
-                IFNULL(bce.Op, 0) AS Op
+                SUM(IFNULL(bce.needCount, 0)) AS QtyRequired,
+                IFNULL(MAX(im.Um), '') AS Unit,
+                IFNULL(MAX(im.Location), '') AS Location,
+                IFNULL(MAX(bce.Op), 0) AS Op
             FROM b_examine_result ber
             INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.is_use = 1
             LEFT JOIN ItemMaster im ON bce.item_number = im.ItemNum
@@ -106,11 +143,9 @@ public class WorkOrderMaterialDetailSyncService : ITransient
               AND IFNULL(bce.num, '') <> '1'
               AND IFNULL(bce.backflush, 0) = 0
               AND IFNULL(bce.erp_cls, 3) <> 4
-              AND (
-                  IFNULL(bce.use_qty, 0) > 0
-                  OR IFNULL(bce.needCount, 0) > 0
-              )
-            ORDER BY bce.id
+            GROUP BY bce.item_number
+            HAVING SUM(IFNULL(bce.needCount, 0)) > 0
+            ORDER BY MIN(bce.id)
             """,
             new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@WorkOrd", workOrd));
@@ -131,4 +166,11 @@ public class WorkOrderMaterialDetailSyncService : ITransient
         public string? Location { get; set; }
         public int Op { get; set; }
     }
+
+    private sealed class ExistingDetailRow
+    {
+        public long RecID { get; set; }
+        public string ItemNum { get; set; } = string.Empty;
+        public decimal QtyRequired { get; set; }
+    }
 }