Prechádzať zdrojové kódy

feat: 排程模块增强 - 设备/人员产能约束、复合编码解析、line/site回退、MoldAllocationCount写入

Pengxy 3 mesiacov pred
rodič
commit
401fb3fc4c

+ 1 - 1
Web/package.json

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

+ 3 - 1
Web/src/views/aidop/api/workOrderDispatch.ts

@@ -94,7 +94,9 @@ export type WorkOrderReleaseResponse = Record<string, unknown>;
  */
 export async function releaseWorkOrder(body: WorkOrderReleaseParams): Promise<WorkOrderReleaseResponse> {
 	const { userAccount: _ua, ...releaseBody } = body;
-	const res = await service.post('/api/WorkOrder/dispatch/release', releaseBody);
+	const res = await service.post('/api/WorkOrder/dispatch/release', releaseBody, {
+		headers: { 'X-Silent-Error': '1' },
+	});
 	const data = res.data as WorkOrderReleaseResponse | undefined;
 	if (data == null || typeof data !== 'object') {
 		throw new Error('工单下达返回异常');

+ 15 - 1
Web/src/views/aidop/business/workOrderDispatchForm.vue

@@ -171,7 +171,21 @@ async function onSave() {
 		ElMessage.success(serverMsg || '工单下达成功');
 		emit('saved');
 	} catch (e: any) {
-		ElMessage.error(e?.message || '工单下达失败');
+		// 从后端响应中提取业务消息,兼容多种返回格式
+		const rd = e?.response?.data;
+		let msg = '';
+		if (rd && typeof rd === 'object') {
+			msg = (typeof rd.message === 'string' && rd.message.trim())
+				|| (typeof rd.title === 'string' && rd.title.trim())
+				|| (rd.errors ? (typeof rd.errors === 'string' ? rd.errors : JSON.stringify(rd.errors)) : '')
+				|| '';
+		} else if (typeof rd === 'string' && rd.trim()) {
+			msg = rd.trim();
+		}
+		if (!msg && typeof e?.message === 'string' && !e.message.includes('status code')) {
+			msg = e.message;
+		}
+		ElMessage.error(msg || '工单下达失败');
 	} finally {
 		saving.value = false;
 	}

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

+ 18 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.212.sql

@@ -0,0 +1,18 @@
+SET NAMES utf8mb4;
+SET @db = DATABASE();
+
+-- 1. ScheduleResultOpMaster 增加 PeriodDetRecId 列(关联 PeriodSequenceDet.RecID,一对一)
+SET @sql = (SELECT IF(
+    COUNT(*) = 0,
+    'ALTER TABLE ScheduleResultOpMaster ADD COLUMN PeriodDetRecId BIGINT NULL COMMENT ''关联PeriodSequenceDet.RecID(一对一)''',
+    'SELECT ''ScheduleResultOpMaster.PeriodDetRecId already exists'' AS info'
+) FROM INFORMATION_SCHEMA.COLUMNS
+WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'ScheduleResultOpMaster' AND COLUMN_NAME = 'PeriodDetRecId');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 2. 为 PeriodDetRecId 建索引(加速 JOIN)
+SET @idx = 'idx_srom_perioddetrecid';
+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, '(PeriodDetRecId)'), 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/IqcReturnQueryService.cs

@@ -45,7 +45,7 @@ public class IqcReturnQueryService : IDynamicApiController, ITransient
             ["finspeenddate"] = "t.FInspeEndDate",
         };
         if (!map.TryGetValue(col, out var sqlCol))
-            sqlCol = "t.FApplyTime";
+            return "ORDER BY t.FApplyTime DESC, t.EntryId ASC";
         return $"ORDER BY {sqlCol} {ord}, t.EntryId ASC";
     }
 

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/PurchaseReturnService.cs

@@ -49,7 +49,7 @@ public class PurchaseReturnService : IDynamicApiController, ITransient
             ["suppname"] = "SuppName",
         };
         if (!map.TryGetValue(col, out var sqlCol))
-            sqlCol = "RecID";
+            return "ORDER BY RctDate DESC, DetailRecId ASC";
         return $"ORDER BY {sqlCol} {ord}, DetailRecId ASC";
     }
 

+ 4 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/ScheduleResultOpMaster.cs

@@ -100,4 +100,8 @@ public class ScheduleResultOpMaster
     /// <summary>租户ID(多租户隔离)</summary>
     [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
     public long? TenantId { get; set; }
+
+    /// <summary>关联 PeriodSequenceDet.RecID(一对一)</summary>
+    [SugarColumn(ColumnName = "PeriodDetRecId", IsNullable = true)]
+    public long? PeriodDetRecId { get; set; }
 }

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

@@ -208,7 +208,7 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
             i.PkgCode AS Languages,
             w.LotSerial AS LotSerial,
             CASE WHEN IFNULL(p.Status,'') = '' THEN '否' ELSE '是' END AS IsLabor,
-            IFNULL(r.MachBdnRate, pd.Rate) AS Rate,
+            pd.Rate AS Rate,
             p.Op AS Op,
             p.PlanDate AS PlanDate,
             p.OrdQty AS OrdQty,
@@ -258,7 +258,7 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
         LEFT JOIN WorkOrdRouting r ON p.Op = r.OP AND p.ItemNum = r.ItemNum AND p.WorkOrds = r.WorkOrd
         LEFT JOIN mes_morder m ON w.WorkOrd = m.morder_no AND CAST(w.`Domain` AS CHAR(64)) = CAST(m.factory_id AS CHAR(64))
         LEFT JOIN ProdLineDetail pd ON p.ItemNum = pd.Part AND p.Line = pd.Line AND p.Op = pd.Op
-        LEFT JOIN ScheduleResultOpMaster sm ON p.UDate2 = sm.WorkActivateTime AND p.ItemNum = sm.ItemNum AND sm.WorkOrd = p.WorkOrds AND sm.Op = p.Op
+        LEFT JOIN ScheduleResultOpMaster sm ON sm.PeriodDetRecId = p.RecID
         LEFT JOIN WorkCtrMaster wm ON wm.WorkCtr = sm.WorkCtr 
         LEFT JOIN (
             SELECT SUM(CompQty) AS CompQty, SUM(RejectQty) AS RejectQty, PlanDate, `Period` AS UInt2, Op, WorkOrds, ItemNum
@@ -285,6 +285,9 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
             "lotserial" => "t.LotSerial",
             _ => "t.PlanDate"
         };
+        // 默认(无排序参数时)按计划日期倒序
+        if (string.IsNullOrWhiteSpace(key))
+            return "t.PlanDate DESC, t.Id DESC";
         return $"{col} {dir}, t.Id DESC";
     }
 

+ 210 - 44
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ProductionScheduleGenerationService.cs

@@ -15,7 +15,7 @@ public class ProductionScheduleGenerationService : ITransient
         _kittingCheck = kittingCheck;
     }
 
-    public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account, bool enableCapacityConstraint = false)
+    public async Task<ScheduleGenerationResult> GenerateAsync(long tenantId, string? domain, string account, bool enableCapacityConstraint = true)
     {
         var workOrders = await LoadPendingWorkOrdersAsync(tenantId);
         if (workOrders.Count == 0)
@@ -29,7 +29,7 @@ public class ProductionScheduleGenerationService : ITransient
         string workOrd,
         string? domain,
         string account,
-        bool enableCapacityConstraint = false)
+        bool enableCapacityConstraint = true)
     {
         var rows = await _db.Ado.SqlQueryAsync<PendingWorkOrderRow>(
             """
@@ -53,7 +53,7 @@ public class ProductionScheduleGenerationService : ITransient
         string? domain,
         string account,
         List<PendingWorkOrderRow> workOrders,
-        bool enableCapacityConstraint = false)
+        bool enableCapacityConstraint = true)
     {
         var totalSw = Stopwatch.StartNew();
         var now = DateTime.Now;
@@ -91,7 +91,7 @@ public class ProductionScheduleGenerationService : ITransient
             var woSw = Stopwatch.StartNew();
 
             // 0. 先加载工艺路线,无路线的工单跳过齐套检查(性能优化)
-            var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd);
+            var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd, wo.ItemNum);
             if (routings.Count == 0)
             {
                 skipped.Add($"{wo.WorkOrd}(无工艺路线)");
@@ -200,20 +200,31 @@ public class ProductionScheduleGenerationService : ITransient
                 var slotKey = BuildOccupancyKey(routing.WorkCtr, planDate);
                 occupiedSlots.Add(slotKey);
 
+                // 始终解析设备编码和技能编号(供 ScheduleResultOpMaster 写入)
+                var capacityMachineCode = await ResolveMachineCodeAsync(tenantId, routing.Machine, wo.ItemNum, routing.Op);
+                var capacitySkillNo = await ResolveSkillNoAsync(tenantId, routing.Engineer, wo.ItemNum, routing.Op);
+
+                var isNonManual = !string.IsNullOrWhiteSpace(routing.WorkCode)
+                    ? routing.WorkCode.Trim().ToUpperInvariant() != "P"
+                    : (!string.IsNullOrWhiteSpace(routing.OpType)
+                        && routing.OpType.Trim().ToUpperInvariant() != "P");
+
+                // 非人工工序:无设备可用时写入排产异常
+                if (isNonManual && string.IsNullOrWhiteSpace(capacityMachineCode))
+                {
+                    await InsertScheduleExceptionAsync(tenantId, wo, domain, "无设备可用",
+                        $"工单{wo.WorkOrd}工序{routing.Op}无设备可用");
+                }
+
+                // 始终查询设备可用数量(用于产能约束和兜底上限)
+                var equipCount = await LoadAvailableEquipmentCountAsync(tenantId, capacityMachineCode);
+
                 // 产能约束:非人工工序,根据设备和人员瓶颈限制排产数量
                 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")
+                if (enableCapacityConstraint && isNonManual)
                 {
-                    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);
@@ -238,19 +249,24 @@ public class ProductionScheduleGenerationService : ITransient
                     }
                 }
 
+                // 设备占用数兜底:取 MachinesperOp(但不超过实际设备可用数),默认 1
+                if (!capacityDeviceAllocation.HasValue)
+                {
+                    var desired = routing.MachinestPerOp > 0 ? routing.MachinestPerOp.Value : 1;
+                    capacityDeviceAllocation = equipCount > 0 ? Math.Min(desired, equipCount) : desired;
+                }
+
                 var recId = await NextPeriodRecIdAsync();
                 await InsertScheduleRowAsync(
                     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);
-                }
+                // 始终写入 ScheduleResultOpMaster(基础排产数据 + 可选产能分配数据)
+                var resultRecId = await NextScheduleResultOpRecIdAsync();
+                await InsertScheduleResultOpAsync(
+                    resultRecId, woDomain, routing, wo, planDate, seq, now, tenantId,
+                    capacityMachineCode, capacityDeviceAllocation, capacitySkillNo, capacityAssignedPersonnel,
+                    periodDetRecId: recId);
                 seq++;
                 rowCount++;
             }
@@ -436,25 +452,69 @@ public class ProductionScheduleGenerationService : ITransient
             new SugarParameter("@TenantId", tenantId));
     }
 
-    private async Task<List<RoutingRow>> LoadRoutingsAsync(long tenantId, string workOrd)
+    private async Task<List<RoutingRow>> LoadRoutingsAsync(long tenantId, string workOrd, string? itemNum = null)
     {
-        return await _db.Ado.SqlQueryAsync<RoutingRow>(
+        var routings = await _db.Ado.SqlQueryAsync<RoutingRow>(
             """
-            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
+            SELECT r.OP AS Op,
+                   CASE WHEN IFNULL(r.ProdLine, '') = '' THEN pd.Line ELSE r.ProdLine END AS ProdLine,
+                   r.WorkCtr,
+                   CASE WHEN IFNULL(r.WorkCtr, '') = '' THEN IFNULL(pd.Site, '') ELSE r.WorkCtr END AS Site,
+                   IFNULL(r.StdOp, '') AS StdOp,
+                   IFNULL(r.Machine, '') AS Machine,
+                   IFNULL(r.Engineer, '') AS Engineer,
+                   IFNULL(r.RunCrew, 0) AS RunCrew,
+                   IFNULL(r.MachBdnRate, 0) AS MachBdnRate,
+                   IFNULL(r.MachinesperOp, 0) AS MachinestPerOp,
+                   IFNULL(r.RunTime, 0) AS RunTime,
+                   IFNULL(r.WorkCode, '') AS WorkCode,
+                   IFNULL(pd.OpType, '') AS OpType,
+                   IFNULL(r.Descr, '') AS OpDescr,
+                   IFNULL(r.StdSetupTime, 0) AS SetupTime
+            FROM WorkOrdRouting r
+            LEFT JOIN ProdLineDetail pd ON pd.Part = r.ItemNum AND pd.Op = r.OP
+            WHERE r.tenant_id = @TenantId AND r.WorkOrd = @WorkOrd AND IFNULL(r.IsActive, 0) = 1
+            ORDER BY (r.OP + 0), r.OP
             """,
             new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@WorkOrd", workOrd));
+
+        // ── C# 层补充回退:若 SQL JOIN 未命中,用 WorkOrdMaster.ItemNum 直接查 ProdLineDetail ──
+        var needFill = routings.Any(r => string.IsNullOrWhiteSpace(r.ProdLine)
+            || string.IsNullOrWhiteSpace(r.Site) || string.IsNullOrWhiteSpace(r.OpType));
+        if (needFill && !string.IsNullOrWhiteSpace(itemNum))
+        {
+            var fallback = await _db.Ado.SqlQueryAsync<ProdLineFallbackRow>(
+                """
+                SELECT Op, Line, Site, OpType
+                FROM ProdLineDetail
+                WHERE Part = @Part
+                """,
+                new SugarParameter("@Part", itemNum));
+            var fbMap = fallback.ToDictionary(f => f.Op, f => f);
+            foreach (var r in routings)
+            {
+                if (fbMap.TryGetValue(r.Op, out var fb))
+                {
+                    if (string.IsNullOrWhiteSpace(r.ProdLine) && !string.IsNullOrWhiteSpace(fb.Line))
+                        r.ProdLine = fb.Line;
+                    if (string.IsNullOrWhiteSpace(r.Site) && !string.IsNullOrWhiteSpace(fb.Site))
+                        r.Site = fb.Site;
+                    if (string.IsNullOrWhiteSpace(r.OpType) && !string.IsNullOrWhiteSpace(fb.OpType))
+                        r.OpType = fb.OpType;
+                }
+            }
+        }
+
+        return routings;
+    }
+
+    private sealed class ProdLineFallbackRow
+    {
+        public int Op { get; set; }
+        public string? Line { get; set; }
+        public string? Site { get; set; }
+        public string? OpType { get; set; }
     }
 
     public async Task DeactivateExistingScheduleAsync(long tenantId, string workOrd)
@@ -468,6 +528,15 @@ public class ProductionScheduleGenerationService : ITransient
             new SugarParameter("@Now", DateTime.Now),
             new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@WorkOrd", workOrd));
+
+        // 清理旧的排产结果数据
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            DELETE FROM ScheduleResultOpMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
     }
 
     private static string ResolveDomain(string? woDomain, string? requestDomain, long tenantId)
@@ -497,25 +566,109 @@ public class ProductionScheduleGenerationService : ITransient
         return max + 1;
     }
 
-    /// <summary>查询可用设备数量(仅排产设备)。</summary>
+    /// <summary>
+    /// 查询可用设备数量(仅排产设备)。
+    /// 支持复合编码:"/" 分隔表示任一可用(SUM),"+" 分隔表示需同时使用(MIN)。
+    /// </summary>
     private async Task<int> LoadAvailableEquipmentCountAsync(long tenantId, string? machineCode)
     {
         if (string.IsNullOrWhiteSpace(machineCode)) return 0;
+
+        var code = machineCode.Trim();
+
+        // "+" 分隔:需要所有设备同时使用 → 取 MIN
+        if (code.Contains('+'))
+        {
+            var parts = code.Split('+', StringSplitOptions.RemoveEmptyEntries)
+                            .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
+            if (parts.Length == 0) return 0;
+            var counts = new List<int>();
+            foreach (var part in parts)
+            {
+                var c = await CountSingleEquipmentAsync(part);
+                counts.Add(c);
+            }
+            return counts.Count > 0 ? counts.Min() : 0;
+        }
+
+        // "/" 分隔:任一设备可用 → 取 SUM
+        if (code.Contains('/'))
+        {
+            var parts = code.Split('/', StringSplitOptions.RemoveEmptyEntries)
+                            .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
+            if (parts.Length == 0) return 0;
+            var total = 0;
+            foreach (var part in parts)
+            {
+                total += await CountSingleEquipmentAsync(part);
+            }
+            return total;
+        }
+
+        // 单设备编码
+        return await CountSingleEquipmentAsync(code);
+    }
+
+    /// <summary>查询单个设备编码的可用设备数量。</summary>
+    private async Task<int> CountSingleEquipmentAsync(string code)
+    {
         return await _db.Ado.GetIntAsync(
             """
             SELECT COUNT(*) FROM EquipmentList
             WHERE InternalEquipmentCode = @Code AND IFNULL(IsSchedulable, 0) = 1
             """,
-            new SugarParameter("@Code", machineCode.Trim()));
+            new SugarParameter("@Code", code));
     }
 
-    /// <summary>查询可用人员数量(持有指定技能的人员)。</summary>
+    /// <summary>
+    /// 查询可用人员数量(持有指定技能的人员)。
+    /// 支持复合编码:"/" 分隔表示任一技能可用(SUM),"+" 分隔表示需同时持有(MIN)。
+    /// </summary>
     private async Task<int> LoadAvailablePersonnelCountAsync(long tenantId, string? skillNo)
     {
         if (string.IsNullOrWhiteSpace(skillNo)) return 0;
+
+        var code = skillNo.Trim();
+
+        // "+" 分隔:需要同时持有所有技能 → 取 MIN
+        if (code.Contains('+'))
+        {
+            var parts = code.Split('+', StringSplitOptions.RemoveEmptyEntries)
+                            .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
+            if (parts.Length == 0) return 0;
+            var counts = new List<int>();
+            foreach (var part in parts)
+            {
+                var c = await CountSingleSkillAsync(part);
+                counts.Add(c);
+            }
+            return counts.Count > 0 ? counts.Min() : 0;
+        }
+
+        // "/" 分隔:任一技能可用 → 取 SUM
+        if (code.Contains('/'))
+        {
+            var parts = code.Split('/', StringSplitOptions.RemoveEmptyEntries)
+                            .Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
+            if (parts.Length == 0) return 0;
+            var total = 0;
+            foreach (var part in parts)
+            {
+                total += await CountSingleSkillAsync(part);
+            }
+            return total;
+        }
+
+        // 单技能编码
+        return await CountSingleSkillAsync(code);
+    }
+
+    /// <summary>查询持有单个技能编码的人员数量。</summary>
+    private async Task<int> CountSingleSkillAsync(string skillNo)
+    {
         return await _db.Ado.GetIntAsync(
             "SELECT COUNT(*) FROM EmpSkills WHERE SkillNo = @SkillNo",
-            new SugarParameter("@SkillNo", skillNo.Trim()));
+            new SugarParameter("@SkillNo", skillNo));
     }
 
     /// <summary>解析设备编码:WorkOrdRouting.Machine 为空时回退查 ProdLineDetail。</summary>
@@ -589,19 +742,24 @@ public class ProductionScheduleGenerationService : ITransient
         string? machineCode,
         int? deviceAllocationCount,
         string? skillNo,
-        decimal? assignedPersonnelCount)
+        decimal? assignedPersonnelCount,
+        long? periodDetRecId)
     {
         await _db.Ado.ExecuteCommandAsync(
             """
             INSERT INTO ScheduleResultOpMaster (
                 RecID, `Domain`, WorkOrd, WorkCtr, Line, ItemNum, Op,
-                WorkDate, WorkQty, WorkSort,
-                InternalEquipmentCode, DeviceAllocationCount, SkillNo, AssignedPersonnelCount,
+                WorkDate, WorkQty, WorkSort, OpDescr, SetupTime, WorkActivateTime,
+                InternalEquipmentCode, DeviceAllocationCount, MoldAllocationCount,
+                SkillNo, AssignedPersonnelCount,
+                PeriodDetRecId,
                 Remark, CreateTime, tenant_id
             ) VALUES (
                 @RecId, @Domain, @WorkOrd, @WorkCtr, @Line, @ItemNum, @Op,
-                @WorkDate, @WorkQty, @WorkSort,
-                @InternalEquipmentCode, @DeviceAllocationCount, @SkillNo, @AssignedPersonnelCount,
+                @WorkDate, @WorkQty, @WorkSort, @OpDescr, @SetupTime, @WorkActivateTime,
+                @InternalEquipmentCode, @DeviceAllocationCount, @MoldAllocationCount,
+                @SkillNo, @AssignedPersonnelCount,
+                @PeriodDetRecId,
                 @Remark, @CreateTime, @TenantId
             )
             """,
@@ -615,10 +773,15 @@ public class ProductionScheduleGenerationService : ITransient
             new SugarParameter("@WorkDate", planDate),
             new SugarParameter("@WorkQty", wo.QtyOrded ?? 0),
             new SugarParameter("@WorkSort", seq),
+            new SugarParameter("@OpDescr", string.IsNullOrWhiteSpace(routing.OpDescr) ? (object)DBNull.Value : routing.OpDescr.Trim()),
+            new SugarParameter("@SetupTime", routing.SetupTime ?? (object)DBNull.Value),
+            new SugarParameter("@WorkActivateTime", now),
             new SugarParameter("@InternalEquipmentCode", string.IsNullOrWhiteSpace(machineCode) ? (object)DBNull.Value : machineCode.Trim()),
             new SugarParameter("@DeviceAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
+            new SugarParameter("@MoldAllocationCount", deviceAllocationCount ?? (object)DBNull.Value),
             new SugarParameter("@SkillNo", string.IsNullOrWhiteSpace(skillNo) ? (object)DBNull.Value : skillNo.Trim()),
             new SugarParameter("@AssignedPersonnelCount", assignedPersonnelCount ?? (object)DBNull.Value),
+            new SugarParameter("@PeriodDetRecId", periodDetRecId ?? (object)DBNull.Value),
             new SugarParameter("@Remark", string.Empty),
             new SugarParameter("@CreateTime", now),
             new SugarParameter("@TenantId", tenantId));
@@ -758,6 +921,9 @@ public class ProductionScheduleGenerationService : ITransient
         public int? MachinestPerOp { get; set; }
         public decimal? RunTime { get; set; }
         public string? WorkCode { get; set; }
+        public string? OpType { get; set; }
+        public string? OpDescr { get; set; }
+        public decimal? SetupTime { get; set; }
     }
 
     private sealed class CalendarRow

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/DeliveryScheduleService.cs

@@ -1342,7 +1342,7 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
             "restqty" => $"ss.restqty {dir}",
             "submitdate" => $"ss.submitdate {dir}",
             "ponumber" => $"ss.ponumber {dir}",
-            _ => "ss.Id DESC"
+            _ => "ss.dsnum DESC"
         };
     }
 

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseOrderService.cs

@@ -236,7 +236,7 @@ public class PurchaseOrderService : IDynamicApiController, ITransient
             "jhrq" => $"a.jhrq {dir}",
             "th" => $"a.th {dir}",
             "sffb" => $"b2.hfsj {dir}",
-            _ => "a.id DESC"
+            _ => "a.jhrq DESC"
         };
     }
 

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestService.cs

@@ -324,7 +324,7 @@ public class PurchaseRequestService : IDynamicApiController, ITransient
             "prssenddate" => $"pm.pr_ssend_date {dir}",
             "prsarrivedate" => $"pm.pr_sarrive_date {dir}",
             "createtime" => $"pm.create_time {dir}",
-            _ => "pm.id DESC"
+            _ => "pm.create_time DESC"
         };
     }
 

+ 28 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs

@@ -183,6 +183,33 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
         {
             await AssertInitialWorkOrderExistsAsync(tid, wo);
 
+            // ── 下达前置校验 ──
+            // 1. 生产批号必须有值
+            if (string.IsNullOrWhiteSpace(input.LotSerial))
+                throw Oops.Oh("工单下达失败:生产批号不能为空,请先获取或输入生产批号");
+
+            // 2. 工单物料明细至少一条
+            var detailCount = await _db.Ado.GetIntAsync(
+                """
+                SELECT COUNT(*) FROM WorkOrdDetail
+                WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+                """,
+                new SugarParameter("@TenantId", tid),
+                new SugarParameter("@WorkOrd", wo));
+            if (detailCount == 0)
+                throw Oops.Oh("工单下达失败:工单物料明细为空,请先同步物料明细");
+
+            // 3. 工单工序明细至少一条
+            var routingCount = await _db.Ado.GetIntAsync(
+                """
+                SELECT COUNT(*) FROM WorkOrdRouting
+                WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+                """,
+                new SugarParameter("@TenantId", tid),
+                new SugarParameter("@WorkOrd", wo));
+            if (routingCount == 0)
+                throw Oops.Oh("工单下达失败:工单工序明细为空,请先同步工艺路线");
+
             if (!string.IsNullOrWhiteSpace(input.LotSerial))
             {
                 var ls = input.LotSerial.Trim();
@@ -238,6 +265,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                         END,
                         LotSerial   = @LotSerial,
                         ReleaseDate = @ReleaseDate,
+                        Eff         = @ReleaseDate,
                         UpdateUser  = @UpdateUser,
                         UpdateTime  = @UpdateTime,
                         Batch       = SUBSTRING(@WorkOrd, 2)