Browse Source

feat: 生产排程性能优化 - 先加载工艺路线再做齐套检查,跳过无路线工单; 添加tenant_id索引迁移; PeriodSequenceDet去掉Domain条件

Pengxy 7 hours ago
parent
commit
6a74f4a096

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "admin.net",
   "type": "module",
-  "version": "2.4.205",
+  "version": "2.4.206",
   "packageManager": "pnpm@10.32.1",
   "lastBuildTime": "2026.03.15",
   "description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 4 - 0
Web/src/views/aidop/api/seOrderReview.ts

@@ -150,6 +150,10 @@ export function createSeOrderChange(id: number, body: SeOrderChangeSave) {
 	return service.post(`/api/Order/seorder/${id}/change`, body).then((r) => r.data);
 }
 
+export function fetchSeOrderChange(id: number) {
+	return service.get<{ changeReason: string; changeType: string; changeContent: string }>(`/api/Order/seorder/${id}/change`).then((r) => r.data);
+}
+
 export function fetchCustomers(params: Record<string, unknown>) {
 	return service.get<Paged<SimpleKv>>('/api/Order/seorder/customers', { params }).then((r) => r.data);
 }

+ 17 - 7
Web/src/views/aidop/business/salesOrderChangeForm.vue

@@ -33,7 +33,7 @@
 
 				<el-col :span="12">
 					<el-form-item label="变更原因" prop="changeReason">
-						<el-select v-model="form.changeReason" style="width: 100%">
+						<el-select v-model="form.changeReason" :disabled="props.readonly" style="width: 100%">
 							<el-option label="客户原因" value="客户原因" />
 							<el-option label="公司原因" value="公司原因" />
 						</el-select>
@@ -41,7 +41,7 @@
 				</el-col>
 				<el-col :span="12">
 					<el-form-item label="变更要求" prop="changeType">
-						<el-select v-model="form.changeType" style="width: 100%">
+						<el-select v-model="form.changeType" :disabled="props.readonly" style="width: 100%">
 							<el-option label="变更订单" value="变更订单" />
 							<el-option label="取消订单" value="取消订单" />
 						</el-select>
@@ -49,7 +49,7 @@
 				</el-col>
 				<el-col :span="24">
 					<el-form-item label="变更内容" prop="changeContent">
-						<el-input v-model="form.changeContent" type="textarea" rows="3" />
+						<el-input v-model="form.changeContent" type="textarea" rows="3" :disabled="props.readonly" />
 					</el-form-item>
 				</el-col>
 			</el-row>
@@ -66,9 +66,9 @@
 			</el-table>
 		</el-form>
 
-		<!-- 审批流程面板 -->
+		<!-- 审批流程面板(只读模式下隐藏) -->
 		<ApprovalPanel
-			v-if="form.seOrderId"
+			v-if="form.seOrderId && !props.readonly"
 			bizType="ORDER_CHANGE_REVIEW"
 			:bizId="form.seOrderId"
 			:bizNo="form.billNo"
@@ -87,9 +87,9 @@
 import { computed, onMounted, reactive, ref } from 'vue';
 import { FormInstance, FormRules } from 'element-plus';
 import ApprovalPanel from '/@/views/approvalFlow/component/ApprovalPanel.vue';
-import { createSeOrderChange, fetchSeOrderDetail, type SeOrderChangeSave } from '../api/seOrderReview';
+import { createSeOrderChange, fetchSeOrderDetail, fetchSeOrderChange, type SeOrderChangeSave } from '../api/seOrderReview';
 
-const props = defineProps<{ orderId: number | null }>();
+const props = defineProps<{ orderId: number | null; readonly?: boolean }>();
 const emit = defineEmits<{ (e: 'cancel'): void; (e: 'submitted'): void }>();
 
 const loading = ref(false);
@@ -140,6 +140,16 @@ async function loadDetail() {
 			...x,
 			progressText: x.progress == null ? '' : String(x.progress),
 		}));
+
+		// 只读模式下加载变更记录
+		if (props.readonly) {
+			const change = await fetchSeOrderChange(props.orderId);
+			if (change) {
+				form.changeReason = change.changeReason || '';
+				form.changeType = change.changeType || '';
+				form.changeContent = change.changeContent || '';
+			}
+		}
 	} finally {
 		loading.value = false;
 	}

+ 3 - 2
Web/src/views/approvalFlow/center/components/DoneList.vue

@@ -11,8 +11,9 @@
 		</el-table-column>
 		<el-table-column prop="comment" label="意见" show-overflow-tooltip class-name="mobile-hide" label-class-name="mobile-hide" />
 		<el-table-column prop="actionTime" label="操作时间" width="180" class-name="mobile-hide" label-class-name="mobile-hide" />
-		<el-table-column label="操作" width="80" align="center" fixed="right">
+		<el-table-column label="操作" width="160" align="center" fixed="right">
 			<template #default="{ row }">
+				<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
 				<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
 			</template>
 		</el-table-column>
@@ -21,7 +22,7 @@
 
 <script setup lang="ts">
 defineProps<{ data: any[]; loading: boolean }>();
-const emit = defineEmits<{ (e: 'timeline', row: any): void }>();
+const emit = defineEmits<{ (e: 'timeline', row: any): void; (e: 'viewBiz', row: any): void }>();
 
 const taskStatusLabel = (s: number) => {
 	const map: Record<number, string> = { 0: '待审批', 1: '已同意', 2: '已拒绝', 3: '已转办', 4: '已取消', 5: '已退回' };

+ 3 - 1
Web/src/views/approvalFlow/center/components/InitiatedList.vue

@@ -16,8 +16,9 @@
 			</template>
 		</el-table-column>
 		<el-table-column prop="startTime" label="发起时间" width="180" class-name="mobile-hide" label-class-name="mobile-hide" />
-		<el-table-column label="操作" width="180" align="center" fixed="right">
+		<el-table-column label="操作" width="240" align="center" fixed="right">
 			<template #default="{ row }">
+				<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
 				<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
 				<el-button v-if="row.status === 1" size="small" text type="primary" @click="emit('urge', row)">催办</el-button>
 				<el-button v-if="row.status === 1" size="small" text type="warning" @click="emit('withdraw', row)">撤回</el-button>
@@ -32,6 +33,7 @@ const emit = defineEmits<{
 	(e: 'timeline', row: any): void;
 	(e: 'urge', row: any): void;
 	(e: 'withdraw', row: any): void;
+	(e: 'viewBiz', row: any): void;
 }>();
 
 const instanceStatusLabel = (s: number) => {

+ 2 - 0
Web/src/views/approvalFlow/center/components/PendingList.vue

@@ -32,6 +32,7 @@
 						<el-text type="info" size="small">请进入异常详情处理</el-text>
 					</template>
 					<template v-else>
+						<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
 						<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">
@@ -91,6 +92,7 @@ const emit = defineEmits<{
 	(e: 'timeline', row: any): void;
 	(e: 'batchApprove', rows: any[]): void;
 	(e: 'batchReject', rows: any[]): void;
+	(e: 'viewBiz', row: any): void;
 }>();
 
 const selectedRows = ref<any[]>([]);

+ 28 - 9
Web/src/views/approvalFlow/center/index.vue

@@ -40,9 +40,10 @@
 				@timeline="openTimeline"
 				@batchApprove="doBatchApprove"
 				@batchReject="doBatchReject"
+				@viewBiz="openBizForm"
 			/>
-			<DoneList v-if="activeTab === 'done'" :data="tableData" :loading="loading" @timeline="openTimeline" />
-			<InitiatedList v-if="activeTab === 'initiated'" :data="tableData" :loading="loading" @timeline="openTimeline" @urge="doUrge" @withdraw="doWithdraw" />
+			<DoneList v-if="activeTab === 'done'" :data="tableData" :loading="loading" @timeline="openTimeline" @viewBiz="openBizForm" />
+			<InitiatedList v-if="activeTab === 'initiated'" :data="tableData" :loading="loading" @timeline="openTimeline" @urge="doUrge" @withdraw="doWithdraw" @viewBiz="openBizForm" />
 
 			<el-pagination
 				v-model:page-size="pageSize"
@@ -67,6 +68,11 @@
 			:current-node-ids="timelineCurrentNodeIds"
 			:completed-node-ids="timelineCompletedNodeIds"
 		/>
+
+		<!-- 业务表单对话框(订单变更等) -->
+		<el-dialog v-model="bizFormVisible" :title="bizFormTitle" width="980px" destroy-on-close>
+			<SalesOrderChangeForm v-if="bizFormBizType === 'ORDER_CHANGE_REVIEW'" :order-id="bizFormBizId" :readonly="true" @cancel="bizFormVisible = false" />
+		</el-dialog>
 	</div>
 </template>
 
@@ -96,6 +102,7 @@ import DoneList from './components/DoneList.vue';
 import InitiatedList from './components/InitiatedList.vue';
 import ApprovalDialog from './components/ApprovalDialog.vue';
 import TimelineDialog from './components/TimelineDialog.vue';
+import SalesOrderChangeForm from '/@/views/aidop/business/salesOrderChangeForm.vue';
 
 import type { ActionType } from './components/ApprovalDialog.vue';
 
@@ -114,7 +121,7 @@ const loadBizTypes = async () => {
 	try {
 		const res = await getBizTypeList();
 		bizTypeOptions.value = res.data?.result ?? [];
-	} catch {}
+	} catch { /* 业务类型加载失败保持空列表 */ }
 };
 
 const resetFilter = () => {
@@ -135,6 +142,11 @@ const timelineCurrentNodeId = ref<string | undefined>(undefined);
 const timelineCurrentNodeIds = ref<string[]>([]);
 const timelineCompletedNodeIds = ref<string[]>([]);
 
+const bizFormVisible = ref(false);
+const bizFormBizType = ref('');
+const bizFormBizId = ref<number>(0);
+const bizFormTitle = ref('');
+
 onMounted(() => {
 	loadBizTypes();
 	loadData();
@@ -150,7 +162,7 @@ const loadPendingCount = async () => {
 	try {
 		const res = await myPendingCount();
 		pendingCount.value = res.data?.result ?? 0;
-	} catch {}
+	} catch { /* 待办数获取失败保持0 */ }
 };
 
 const loadData = async () => {
@@ -213,7 +225,7 @@ const doReturnToPrev = async (row: any) => {
 		await returnToPrev({ taskId: row.id });
 		ElMessage.success('已退回');
 		loadData();
-	} catch {}
+	} catch { /* 用户取消或接口异常静默处理 */ }
 };
 
 const doUrge = async (row: any) => {
@@ -221,7 +233,7 @@ const doUrge = async (row: any) => {
 		const instanceId = row.instanceId || row.id;
 		await urgeFlow({ instanceId });
 		ElMessage.success('催办成功');
-	} catch {}
+	} catch { /* 催办失败静默处理 */ }
 };
 
 const doWithdraw = async (row: any) => {
@@ -230,7 +242,7 @@ const doWithdraw = async (row: any) => {
 		await withdrawFlow({ instanceId: row.id });
 		ElMessage.success('已撤回');
 		loadData();
-	} catch {}
+	} catch { /* 用户取消或接口异常静默处理 */ }
 };
 
 const doBatchApprove = async (rows: any[]) => {
@@ -251,7 +263,7 @@ const doBatchApprove = async (rows: any[]) => {
 			loadData();
 			loadPendingCount();
 		});
-	} catch {}
+	} catch { /* 用户取消或接口异常静默处理 */ }
 };
 
 const doBatchReject = async (rows: any[]) => {
@@ -272,7 +284,14 @@ const doBatchReject = async (rows: any[]) => {
 			loadData();
 			loadPendingCount();
 		});
-	} catch {}
+	} catch { /* 用户取消或接口异常静默处理 */ }
+};
+
+const openBizForm = (row: any) => {
+	bizFormBizType.value = row.bizType;
+	bizFormBizId.value = row.bizId;
+	bizFormTitle.value = row.title || '查看业务表单';
+	bizFormVisible.value = true;
 };
 
 const openTimeline = async (row: any) => {

+ 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.210</AssemblyVersion>
-    <FileVersion>1.0.210</FileVersion>
-    <Version>1.0.210</Version>
+    <AssemblyVersion>1.0.211</AssemblyVersion>
+    <FileVersion>1.0.211</FileVersion>
+    <Version>1.0.211</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 67 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.208.sql

@@ -0,0 +1,67 @@
+-- =============================================================================
+-- 版本:1.0.208
+-- 目的:为生产排程链路关键表添加 tenant_id 索引,提升多租户查询性能
+-- 影响表:WorkOrdRouting / PeriodSequenceDet / ShopCalendarWorkCtr /
+--         WorkOrdMaster / ScheduleExceptionMaster / ScheduleResultOpMaster
+-- 幂等:通过 information_schema 检查索引是否已存在,重复执行安全
+-- 兼容:MySQL 5.x / 8.x 通用(PREPARE/EXECUTE 模式,无需 DELIMITER)
+-- =============================================================================
+
+SET NAMES utf8mb4;
+SET @db = DATABASE();
+
+-- 1. WorkOrdRouting(tenant_id, WorkOrd) 复合索引
+SET @idx = 'idx_workordrouting_tenant';
+SET @tbl = 'WorkOrdRouting';
+SET @exists = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @db AND table_name = @tbl AND index_name = @idx);
+SET @sql = IF(@exists = 0, CONCAT('CREATE INDEX ', @idx, ' ON ', @tbl, '(tenant_id, WorkOrd)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 2. PeriodSequenceDet(tenant_id) 单列索引
+SET @idx = 'idx_periodseq_tenant';
+SET @tbl = 'PeriodSequenceDet';
+SET @exists = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @db AND table_name = @tbl AND index_name = @idx);
+SET @sql = IF(@exists = 0, CONCAT('CREATE INDEX ', @idx, ' ON ', @tbl, '(tenant_id)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 3. PeriodSequenceDet(tenant_id, WorkOrds) 复合索引 — 用于 DeactivateExistingScheduleAsync
+SET @idx = 'idx_periodseq_deactivate';
+SET @tbl = 'PeriodSequenceDet';
+SET @exists = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @db AND table_name = @tbl AND index_name = @idx);
+SET @sql = IF(@exists = 0, CONCAT('CREATE INDEX ', @idx, ' ON ', @tbl, '(tenant_id, WorkOrds)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 4. ShopCalendarWorkCtr(tenant_id)
+SET @idx = 'idx_shopcalendar_tenant';
+SET @tbl = 'ShopCalendarWorkCtr';
+SET @exists = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @db AND table_name = @tbl AND index_name = @idx);
+SET @sql = IF(@exists = 0, CONCAT('CREATE INDEX ', @idx, ' ON ', @tbl, '(tenant_id)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 5. WorkOrdMaster(tenant_id)
+SET @idx = 'idx_workordmaster_tenant';
+SET @tbl = 'WorkOrdMaster';
+SET @exists = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @db AND table_name = @tbl AND index_name = @idx);
+SET @sql = IF(@exists = 0, CONCAT('CREATE INDEX ', @idx, ' ON ', @tbl, '(tenant_id)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 6. ScheduleExceptionMaster(tenant_id)
+SET @idx = 'idx_scheduleexception_tenant';
+SET @tbl = 'ScheduleExceptionMaster';
+SET @exists = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @db AND table_name = @tbl AND index_name = @idx);
+SET @sql = IF(@exists = 0, CONCAT('CREATE INDEX ', @idx, ' ON ', @tbl, '(tenant_id)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 7. ScheduleResultOpMaster(tenant_id)
+SET @idx = 'idx_scheduleresultop_tenant';
+SET @tbl = 'ScheduleResultOpMaster';
+SET @exists = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @db AND table_name = @tbl AND index_name = @idx);
+SET @sql = IF(@exists = 0, CONCAT('CREATE INDEX ', @idx, ' ON ', @tbl, '(tenant_id)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 8. ScheduleResultOpMaster(WorkOrd, Op) — 高频 JOIN 键
+SET @idx = 'idx_scheduleresultop_workord';
+SET @tbl = 'ScheduleResultOpMaster';
+SET @exists = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @db AND table_name = @tbl AND index_name = @idx);
+SET @sql = IF(@exists = 0, CONCAT('CREATE INDEX ', @idx, ' ON ', @tbl, '(WorkOrd, Op)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 22 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/SeOrderService.cs

@@ -462,6 +462,28 @@ public class SeOrderService : IDynamicApiController, ITransient
         return new { id = entity.Id, message = "变更申请已保存" };
     }
 
+    // ══════════════════════════════════════════════════════════════
+    // 获取变更记录 GET /api/Order/seorder/{id}/change
+    // ══════════════════════════════════════════════════════════════
+    /// <summary>获取订单最新变更记录 🔖</summary>
+    [DisplayName("获取订单变更记录")]
+    [ApiDescriptionSettings(Name = "GetSeOrderChange"), HttpGet("seorder/{id}/change")]
+    public async Task<object> GetChange(long id)
+    {
+        var tenantId = _userManager.TenantId;
+        var change = await _db.Ado.SqlQuerySingleAsync<SeOrderChange>(
+            """
+            SELECT * FROM crm_seorder_change
+            WHERE seorder_id = @Id AND tenant_id = @TenantId AND IsDeleted = 0
+            ORDER BY create_time DESC LIMIT 1
+            """,
+            new SugarParameter("@Id", id),
+            new SugarParameter("@TenantId", tenantId));
+        if (change == null)
+            return new { changeReason = "", changeType = "", changeContent = "" };
+        return new { changeReason = change.ChangeReason ?? "", changeType = change.ChangeType ?? "", changeContent = change.ChangeContent ?? "" };
+    }
+
     // ──────────────── 内部查询结果映射类 ────────────────
 
     private sealed class SeOrderListRow

+ 294 - 18
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ProductionScheduleGenerationService.cs

@@ -1,5 +1,6 @@
 namespace Admin.NET.Plugin.AiDOP.Production;
 
+using System.Diagnostics;
 using Admin.NET.Plugin.AiDOP.WorkOrder;
 
 /// <summary>生产排程生成:为待排工单写入 PeriodSequenceDet(工作日历 + 工作中心冲突避让)。</summary>
@@ -14,20 +15,21 @@ public class ProductionScheduleGenerationService : ITransient
         _kittingCheck = kittingCheck;
     }
 
-    public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account)
+    public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account, bool enableCapacityConstraint = false)
     {
         var workOrders = await LoadPendingWorkOrdersAsync(tenantId);
         if (workOrders.Count == 0)
             return new ScheduleGenerationResult { Message = "没有待排产的工单(状态 p/r)" };
 
-        return await ScheduleWorkOrdersAsync(tenantId, domain, account, workOrders);
+        return await ScheduleWorkOrdersAsync(tenantId, domain, account, workOrders, enableCapacityConstraint);
     }
 
     public async Task<ScheduleGenerationResult> RegenerateForWorkOrderAsync(
         long tenantId,
         string workOrd,
         string? domain,
-        string account)
+        string account,
+        bool enableCapacityConstraint = false)
     {
         var rows = await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
             """
@@ -43,15 +45,17 @@ public class ProductionScheduleGenerationService : ITransient
         if (wo is null)
             return new ScheduleGenerationResult { Message = $"工单 {workOrd} 不存在" };
 
-        return await ScheduleWorkOrdersAsync(tenantId, domain, account, new List<PendingWorkOrderRow> { wo });
+        return await ScheduleWorkOrdersAsync(tenantId, domain, account, new List<PendingWorkOrderRow> { wo }, enableCapacityConstraint);
     }
 
     private async Task<ScheduleGenerationResult> ScheduleWorkOrdersAsync(
         long tenantId,
         string? domain,
         string account,
-        List<PendingWorkOrderRow> workOrders)
+        List<PendingWorkOrderRow> workOrders,
+        bool enableCapacityConstraint = false)
     {
+        var totalSw = Stopwatch.StartNew();
         var now = DateTime.Now;
         var calendar = await LoadWorkCenterCalendarAsync(tenantId);
         var occupiedSlots = await LoadOccupiedSlotsAsync(tenantId);
@@ -62,6 +66,12 @@ public class ProductionScheduleGenerationService : ITransient
         var skipped = new List<string>();
         // 按优先级顺序跟踪各物料已占用库存量(ItemNumber → 已占用数量)
         var consumedStock = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+        // 跟踪每日各工作中心已占用设备数(Key = "WorkCtr|yyyy-MM-dd")
+        var equipmentDailyOccupancy = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+
+        // 诊断计时
+        var kittingMs = 0L;
+        var otherMs = 0L;
 
         // 清理旧的占用记录,确保每次排程重新计算
         const long scheduleBangId = 1;
@@ -74,9 +84,31 @@ public class ProductionScheduleGenerationService : ITransient
             new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@BangId", scheduleBangId));
 
+        var woIndex = 0;
         foreach (var wo in workOrders)
         {
+            woIndex++;
+            var woSw = Stopwatch.StartNew();
+
+            // 0. 先加载工艺路线,无路线的工单跳过齐套检查(性能优化)
+            var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd);
+            if (routings.Count == 0)
+            {
+                skipped.Add($"{wo.WorkOrd}(无工艺路线)");
+                await InsertScheduleExceptionAsync(tenantId, wo, domain, "无工艺路线", "工单无工艺路线,无法生成排程");
+                woSw.Stop();
+                otherMs += woSw.ElapsedMilliseconds;
+                if (woIndex % 5 == 0 || woIndex == workOrders.Count)
+                {
+                    Console.WriteLine($"[排程诊断] {woIndex}/{workOrders.Count} 工单, " +
+                        $"累计: 齐套检查={kittingMs}ms 其他={otherMs}ms 总耗时={totalSw.ElapsedMilliseconds}ms, " +
+                        $"本工单[{wo.WorkOrd}]=跳过(无路线)");
+                }
+                continue;
+            }
+
             // 1. 执行齐套检查,刷新资源检查记录(写入 b_examine_result / b_bom_child_examine)
+            var kitSw = Stopwatch.StartNew();
             try
             {
                 await _kittingCheck.CheckSingleAsync(tenantId, wo.WorkOrd, account, scheduleBangId);
@@ -85,6 +117,8 @@ public class ProductionScheduleGenerationService : ITransient
             {
                 skipped.Add($"齐套检查失败[{wo.WorkOrd}]: {ex.Message}");
             }
+            kitSw.Stop();
+            kittingMs += kitSw.ElapsedMilliseconds;
 
             // 2. 刷新齐套数量(LocationStock),扣减已被高优先级工单占用的库存
             var locationStock = await CalcLocationStockAsync(tenantId, wo.WorkOrd, wo.QtyOrded ?? 0, consumedStock);
@@ -102,15 +136,39 @@ public class ProductionScheduleGenerationService : ITransient
                     new SugarParameter("@WorkOrd", wo.WorkOrd));
             }
 
-            var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd);
-            if (routings.Count == 0)
+            // 检查各道工序的数据完整性(标准工艺路线、设备、技能)
+            var missingStdOp = routings.Where(r => string.IsNullOrWhiteSpace(r.StdOp)).Select(r => r.Op).ToList();
+            var missingWorkCtr = routings.Where(r => string.IsNullOrWhiteSpace(r.WorkCtr)).Select(r => r.Op).ToList();
+            var missingMachine = routings.Where(r => string.IsNullOrWhiteSpace(r.Machine)).Select(r => r.Op).ToList();
+            var missingEngineer = routings.Where(r => string.IsNullOrWhiteSpace(r.Engineer)).Select(r => r.Op).ToList();
+
+            if (missingStdOp.Count > 0)
             {
-                skipped.Add($"{wo.WorkOrd}(无工艺路线)");
-                continue;
+                var ops = string.Join("、", missingStdOp);
+                skipped.Add($"{wo.WorkOrd}(工序{ops}未查到标准工艺路线)");
+                await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到标准工艺路线",
+                    $"工序 {ops} 未查到标准工艺路线,排程可能不准确");
+            }
+
+            var missingEquip = missingWorkCtr.Concat(missingMachine).Distinct().ToList();
+            if (missingEquip.Count > 0)
+            {
+                var ops = string.Join("、", missingEquip);
+                skipped.Add($"{wo.WorkOrd}(工序{ops}未查到设备)");
+                await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到设备",
+                    $"工序 {ops} 未查到设备(WorkCtr或设备编码为空),排程可能不准确");
+            }
+
+            if (missingEngineer.Count > 0)
+            {
+                var ops = string.Join("、", missingEngineer);
+                skipped.Add($"{wo.WorkOrd}(工序{ops}未查到技能)");
+                await InsertScheduleExceptionAsync(tenantId, wo, domain, "未查到技能",
+                    $"工序 {ops} 未查到技能(SkillNo为空),排程可能不准确");
             }
 
             var woDomain = ResolveDomain(wo.Domain, domain, tenantId);
-            await DeactivateExistingScheduleAsync(tenantId, wo.WorkOrd, woDomain);
+            await DeactivateExistingScheduleAsync(tenantId, wo.WorkOrd);
 
             var planStart = (wo.OrdDate ?? now).Date;
             var planEnd = (wo.DueDate ?? planStart.AddDays(Math.Max(routings.Count, 7))).Date;
@@ -142,16 +200,78 @@ public class ProductionScheduleGenerationService : ITransient
                 var slotKey = BuildOccupancyKey(routing.WorkCtr, planDate);
                 occupiedSlots.Add(slotKey);
 
+                // 产能约束:非人工工序,根据设备和人员瓶颈限制排产数量
+                decimal? effectiveQty = null;
+                string? capacityMachineCode = null;
+                string? capacitySkillNo = null;
+                int? capacityDeviceAllocation = null;
+                decimal? capacityAssignedPersonnel = null;
+                if (enableCapacityConstraint
+                    && !string.IsNullOrWhiteSpace(routing.WorkCode)
+                    && routing.WorkCode.Trim().ToUpperInvariant() != "P")
+                {
+                    capacityMachineCode = await ResolveMachineCodeAsync(tenantId, routing.Machine, wo.ItemNum, routing.Op);
+                    capacitySkillNo = await ResolveSkillNoAsync(tenantId, routing.Engineer, wo.ItemNum, routing.Op);
+
+                    var equipCount = await LoadAvailableEquipmentCountAsync(tenantId, capacityMachineCode);
+                    var personnelCount = await LoadAvailablePersonnelCountAsync(tenantId, capacitySkillNo);
+
+                    var maxAvailable = Math.Min(equipCount, personnelCount);
+                    if (maxAvailable > 0 && routing.MachBdnRate.HasValue && routing.MachBdnRate.Value > 0)
+                    {
+                        var workDays = Math.Max(1, (planEnd - planStart).Days);
+                        var dailyQty = Math.Ceiling((wo.QtyOrded ?? 0) / workDays);
+                        var standardNeed = Math.Ceiling(dailyQty / routing.MachBdnRate.Value);
+
+                        var occupancyKey = $"{NormalizeWorkCtr(routing.WorkCtr)}|{planDate:yyyy-MM-dd}";
+                        var alreadyOccupied = equipmentDailyOccupancy.TryGetValue(occupancyKey, out var occ) ? occ : 0m;
+                        var remainingAvailable = (decimal)maxAvailable - alreadyOccupied;
+
+                        if (remainingAvailable > 0)
+                        {
+                            var actualOccupied = Math.Min(standardNeed, remainingAvailable);
+                            equipmentDailyOccupancy[occupancyKey] = alreadyOccupied + actualOccupied;
+                            effectiveQty = Math.Min(wo.QtyOrded ?? 0, actualOccupied * routing.MachBdnRate.Value);
+                            capacityDeviceAllocation = (int)actualOccupied;
+                            capacityAssignedPersonnel = actualOccupied;
+                        }
+                    }
+                }
+
                 var recId = await NextPeriodRecIdAsync();
                 await InsertScheduleRowAsync(
-                    recId, woDomain, routing, wo, planDate, seq, account, now, tenantId);
+                    recId, woDomain, routing, wo, planDate, seq, account, now, tenantId,
+                    capacityQty: effectiveQty);
+
+                // 产能约束启用时,将设备/人员分配数据写入 ScheduleResultOpMaster
+                if (capacityDeviceAllocation.HasValue || !string.IsNullOrWhiteSpace(capacityMachineCode) || !string.IsNullOrWhiteSpace(capacitySkillNo))
+                {
+                    var resultRecId = await NextScheduleResultOpRecIdAsync();
+                    await InsertScheduleResultOpAsync(
+                        resultRecId, woDomain, routing, wo, planDate, seq, now, tenantId,
+                        capacityMachineCode, capacityDeviceAllocation, capacitySkillNo, capacityAssignedPersonnel);
+                }
                 seq++;
                 rowCount++;
             }
 
             scheduledCount++;
+
+            woSw.Stop();
+            otherMs += woSw.ElapsedMilliseconds - kitSw.ElapsedMilliseconds;
+            // 每5个工单输出一次诊断日志
+            if (woIndex % 5 == 0 || woIndex == workOrders.Count)
+            {
+                Console.WriteLine($"[排程诊断] {woIndex}/{workOrders.Count} 工单, " +
+                    $"累计: 齐套检查={kittingMs}ms 其他={otherMs}ms 总耗时={totalSw.ElapsedMilliseconds}ms, " +
+                    $"本工单[{wo.WorkOrd}]={woSw.ElapsedMilliseconds}ms");
+            }
         }
 
+        totalSw.Stop();
+        Console.WriteLine($"[排程诊断] 完成: {workOrders.Count}工单/{rowCount}条, " +
+            $"齐套检查={kittingMs}ms 其他={otherMs}ms 总计={totalSw.ElapsedMilliseconds}ms");
+
         return new ScheduleGenerationResult
         {
             WorkOrderCount = scheduledCount,
@@ -173,8 +293,10 @@ public class ProductionScheduleGenerationService : ITransient
         int seq,
         string account,
         DateTime now,
-        long tenantId)
+        long tenantId,
+        decimal? capacityQty = null)
     {
+        var ordQty = capacityQty ?? wo.QtyOrded ?? 0;
         await _db.Ado.ExecuteCommandAsync(
             """
             INSERT INTO PeriodSequenceDet (
@@ -199,7 +321,7 @@ public class ProductionScheduleGenerationService : ITransient
             new SugarParameter("@ProdDate", planDate),
             new SugarParameter("@PlanDate", planDate),
             new SugarParameter("@Sequence", seq),
-            new SugarParameter("@OrdQty", wo.QtyOrded ?? 0),
+            new SugarParameter("@OrdQty", ordQty),
             new SugarParameter("@WorkOrd", wo.WorkOrd),
             new SugarParameter("@BusinessId", wo.RecId),
             new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
@@ -318,7 +440,15 @@ public class ProductionScheduleGenerationService : ITransient
     {
         return await _db.Ado.SqlQueryAsync<RoutingRow>(
             """
-            SELECT OP AS Op, ProdLine, WorkCtr, WorkCtr AS Site
+            SELECT OP AS Op, ProdLine, WorkCtr, WorkCtr AS Site,
+                   IFNULL(StdOp, '') AS StdOp,
+                   IFNULL(Machine, '') AS Machine,
+                   IFNULL(Engineer, '') AS Engineer,
+                   IFNULL(RunCrew, 0) AS RunCrew,
+                   IFNULL(MachBdnRate, 0) AS MachBdnRate,
+                   IFNULL(MachinesperOp, 0) AS MachinestPerOp,
+                   IFNULL(RunTime, 0) AS RunTime,
+                   IFNULL(WorkCode, '') AS WorkCode
             FROM WorkOrdRouting
             WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1
             ORDER BY (OP + 0), OP
@@ -327,18 +457,17 @@ public class ProductionScheduleGenerationService : ITransient
             new SugarParameter("@WorkOrd", workOrd));
     }
 
-    public async Task DeactivateExistingScheduleAsync(long tenantId, string workOrd, string domain)
+    public async Task DeactivateExistingScheduleAsync(long tenantId, string workOrd)
     {
         await _db.Ado.ExecuteCommandAsync(
             """
             UPDATE PeriodSequenceDet
             SET IsActive = 0, UpdateTime = @Now
-            WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND `Domain` = @Domain AND IFNULL(IsActive, 0) = 1
+            WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND IFNULL(IsActive, 0) = 1
             """,
             new SugarParameter("@Now", DateTime.Now),
             new SugarParameter("@TenantId", tenantId),
-            new SugarParameter("@WorkOrd", workOrd),
-            new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain));
+            new SugarParameter("@WorkOrd", workOrd));
     }
 
     private static string ResolveDomain(string? woDomain, string? requestDomain, long tenantId)
@@ -356,6 +485,145 @@ public class ProductionScheduleGenerationService : ITransient
         return max + 1;
     }
 
+    private async Task<long> NextScheduleExceptionRecIdAsync()
+    {
+        var max = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM ScheduleExceptionMaster");
+        return max + 1;
+    }
+
+    private async Task<long> NextScheduleResultOpRecIdAsync()
+    {
+        var max = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM ScheduleResultOpMaster");
+        return max + 1;
+    }
+
+    /// <summary>查询可用设备数量(仅排产设备)。</summary>
+    private async Task<int> LoadAvailableEquipmentCountAsync(long tenantId, string? machineCode)
+    {
+        if (string.IsNullOrWhiteSpace(machineCode)) return 0;
+        return await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(*) FROM EquipmentList
+            WHERE InternalEquipmentCode = @Code AND IFNULL(IsSchedulable, 0) = 1
+            """,
+            new SugarParameter("@Code", machineCode.Trim()));
+    }
+
+    /// <summary>查询可用人员数量(持有指定技能的人员)。</summary>
+    private async Task<int> LoadAvailablePersonnelCountAsync(long tenantId, string? skillNo)
+    {
+        if (string.IsNullOrWhiteSpace(skillNo)) return 0;
+        return await _db.Ado.GetIntAsync(
+            "SELECT COUNT(*) FROM EmpSkills WHERE SkillNo = @SkillNo",
+            new SugarParameter("@SkillNo", skillNo.Trim()));
+    }
+
+    /// <summary>解析设备编码:WorkOrdRouting.Machine 为空时回退查 ProdLineDetail。</summary>
+    private async Task<string?> ResolveMachineCodeAsync(long tenantId, string? machine, string? itemNum, int op)
+    {
+        if (!string.IsNullOrWhiteSpace(machine)) return machine.Trim();
+        if (string.IsNullOrWhiteSpace(itemNum)) return null;
+        return await _db.Ado.GetStringAsync(
+            """
+            SELECT InternalEquipmentCode FROM ProdLineDetail
+            WHERE `Part` = @ItemNum AND `Op` = @Op AND tenant_id = @TenantId LIMIT 1
+            """,
+            new SugarParameter("@ItemNum", itemNum),
+            new SugarParameter("@Op", op),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    /// <summary>解析技能编码:WorkOrdRouting.Engineer 为空时回退查 ProdLineDetail。</summary>
+    private async Task<string?> ResolveSkillNoAsync(long tenantId, string? engineer, string? itemNum, int op)
+    {
+        if (!string.IsNullOrWhiteSpace(engineer)) return engineer.Trim();
+        if (string.IsNullOrWhiteSpace(itemNum)) return null;
+        return await _db.Ado.GetStringAsync(
+            """
+            SELECT SkillNo FROM ProdLineDetail
+            WHERE `Part` = @ItemNum AND `Op` = @Op AND tenant_id = @TenantId LIMIT 1
+            """,
+            new SugarParameter("@ItemNum", itemNum),
+            new SugarParameter("@Op", op),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    private async Task InsertScheduleExceptionAsync(
+        long tenantId,
+        PendingWorkOrderRow wo,
+        string? domain,
+        string type,
+        string remark)
+    {
+        var recId = await NextScheduleExceptionRecIdAsync();
+        var woDomain = ResolveDomain(wo.Domain, domain, tenantId);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO ScheduleExceptionMaster (
+                RecID, `Domain`, WorkOrd, ItemNum, CreateTime, Remark, Type, OptTime, tenant_id
+            ) VALUES (
+                @RecId, @Domain, @WorkOrd, @ItemNum, @CreateTime, @Remark, @Type, @OptTime, @TenantId
+            )
+            """,
+            new SugarParameter("@RecId", recId),
+            new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
+            new SugarParameter("@WorkOrd", wo.WorkOrd),
+            new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
+            new SugarParameter("@CreateTime", DateTime.Now),
+            new SugarParameter("@Remark", remark),
+            new SugarParameter("@Type", type),
+            new SugarParameter("@OptTime", DateTime.Now.ToString("yyyy-MM-dd HH:mm")),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    /// <summary>写入排产结果(ScheduleResultOpMaster),包含设备/人员产能分配数据。</summary>
+    private async Task InsertScheduleResultOpAsync(
+        long recId,
+        string woDomain,
+        RoutingRow routing,
+        PendingWorkOrderRow wo,
+        DateTime planDate,
+        int seq,
+        DateTime now,
+        long tenantId,
+        string? machineCode,
+        int? deviceAllocationCount,
+        string? skillNo,
+        decimal? assignedPersonnelCount)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO ScheduleResultOpMaster (
+                RecID, `Domain`, WorkOrd, WorkCtr, Line, ItemNum, Op,
+                WorkDate, WorkQty, WorkSort,
+                InternalEquipmentCode, DeviceAllocationCount, SkillNo, AssignedPersonnelCount,
+                Remark, CreateTime, tenant_id
+            ) VALUES (
+                @RecId, @Domain, @WorkOrd, @WorkCtr, @Line, @ItemNum, @Op,
+                @WorkDate, @WorkQty, @WorkSort,
+                @InternalEquipmentCode, @DeviceAllocationCount, @SkillNo, @AssignedPersonnelCount,
+                @Remark, @CreateTime, @TenantId
+            )
+            """,
+            new SugarParameter("@RecId", recId),
+            new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain),
+            new SugarParameter("@WorkOrd", wo.WorkOrd),
+            new SugarParameter("@WorkCtr", routing.WorkCtr ?? (object)DBNull.Value),
+            new SugarParameter("@Line", routing.ProdLine ?? (object)DBNull.Value),
+            new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty),
+            new SugarParameter("@Op", routing.Op),
+            new SugarParameter("@WorkDate", planDate),
+            new SugarParameter("@WorkQty", wo.QtyOrded ?? 0),
+            new SugarParameter("@WorkSort", seq),
+            new SugarParameter("@InternalEquipmentCode", string.IsNullOrWhiteSpace(machineCode) ? (object)DBNull.Value : machineCode.Trim()),
+            new SugarParameter("@DeviceAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
+            new SugarParameter("@SkillNo", string.IsNullOrWhiteSpace(skillNo) ? (object)DBNull.Value : skillNo.Trim()),
+            new SugarParameter("@AssignedPersonnelCount", assignedPersonnelCount ?? (object)DBNull.Value),
+            new SugarParameter("@Remark", string.Empty),
+            new SugarParameter("@CreateTime", now),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
     public sealed class ScheduleGenerationResult
     {
         public int WorkOrderCount { get; set; }
@@ -482,6 +750,14 @@ public class ProductionScheduleGenerationService : ITransient
         public string? ProdLine { get; set; }
         public string? WorkCtr { get; set; }
         public string? Site { get; set; }
+        public string? StdOp { get; set; }
+        public string? Machine { get; set; }
+        public string? Engineer { get; set; }
+        public decimal RunCrew { get; set; }
+        public decimal? MachBdnRate { get; set; }
+        public int? MachinestPerOp { get; set; }
+        public decimal? RunTime { get; set; }
+        public string? WorkCode { get; set; }
     }
 
     private sealed class CalendarRow

+ 5 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ProductionSchedulingActionService.cs

@@ -41,7 +41,7 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
     /// <summary>生成生产排程计划(写入 PeriodSequenceDet)。</summary>
     [DisplayName("生成生产排程")]
     [HttpPost("scheduling/generate")]
-    public async Task<object> GenerateSchedule([FromQuery] string domain)
+    public async Task<object> GenerateSchedule([FromQuery] string domain, [FromQuery] bool enableCapacityConstraint = false)
     {
         var tenantId = ResolveTenantId(domain);
         var account = _userManager.Account ?? "system";
@@ -49,7 +49,7 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
 
         try
         {
-            var result = await _scheduleGen.GenerateAsync(tenantId, domain, account);
+            var result = await _scheduleGen.GenerateAsync(tenantId, domain, account, enableCapacityConstraint);
             await _runLog.SuccessAsync(logId, result.Message, new
             {
                 tenantId,
@@ -180,13 +180,12 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
             ProductionScheduleGenerationService.ScheduleGenerationResult? reschedule = null;
             if (qtyChanged || dueChanged)
             {
-                reschedule = await _scheduleGen.RegenerateForWorkOrderAsync(tenantId, workOrd, input.Domain, account);
+                reschedule = await _scheduleGen.RegenerateForWorkOrderAsync(tenantId, workOrd, input.Domain, account, input.EnableCapacityConstraint);
                 warnings.Add(reschedule.Message);
             }
             else if (priorityChanged)
             {
-                var woDomain = before?.Domain ?? input.Domain;
-                await _scheduleGen.DeactivateExistingScheduleAsync(tenantId, workOrd, woDomain);
+                await _scheduleGen.DeactivateExistingScheduleAsync(tenantId, workOrd);
                 warnings.Add("优先级已变更,原排程已失效,请重新执行批量排程");
             }
 
@@ -680,4 +679,5 @@ public class WorkOrderPriorityRecheckInput
     public string Domain { get; set; } = string.Empty;
     public string? UserAccount { get; set; }
     public string? LotSerial { get; set; }
+    public bool EnableCapacityConstraint { get; set; }
 }