Explorar el Código

feat: MRP采购申请幂等处理 + 订单评审交期更新 + 工单排产同步

- 采购申请生成时检查已有PR,已有则更新数量,无缺料则软删除
- 订单评审后sys_capacity_date强制更新为最新KittingTime
- 工单状态初始化与交期确认逻辑优化
- 后端版本 1.0.199
Pengxy hace 1 mes
padre
commit
82be72f433

BIN
doc/S1产销协同业务需求描述.docx


+ 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.198</AssemblyVersion>
-    <FileVersion>1.0.198</FileVersion>
-    <Version>1.0.198</Version>
+    <AssemblyVersion>1.0.199</AssemblyVersion>
+    <FileVersion>1.0.199</FileVersion>
+    <Version>1.0.199</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 5 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs

@@ -90,6 +90,11 @@ public class MaterialRequirementCalculator : ITransient
         var scrapFactor = 1m + (child.Scrap / 100m);
         var needNoLoss = parentNeedQty * unitQty;
         var needCount = RoundQty(needNoLoss * wastageFactor * scrapFactor);
+
+        if (needCount > 10000 && parentNeedQty > 0 && unitQty > 100)
+        {
+            warnings.Add($"物料 {child.ItemNumber} BOM用量({unitQty}) × 父需求({parentNeedQty}) = 需求数量({needCount}),请核实 BOM 配置");
+        }
         var isVirtual = child.ErpCls == 4;
         var num = $"{parentNum}.{seq}";
         var fid = YitIdHelper.NextId();

+ 34 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderReviewOrchestrationService.cs

@@ -1,4 +1,5 @@
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.WorkOrder;
 
 namespace Admin.NET.Plugin.AiDOP.Order;
 
@@ -15,6 +16,8 @@ public class OrderReviewOrchestrationService : ITransient
     private readonly UserManager _userManager;
     private readonly OrderWorkOrderGenerationService _workOrderGen;
     private readonly OrderResourceCheckService _resourceCheck;
+    private readonly WorkOrderMaterialDetailSyncService _materialDetailSync;
+    private readonly WorkOrderRoutingSyncService _routingSync;
     private readonly S1MdpSyncTransformService _mdpSync;
     private readonly AidopActionRunLogWriter _runLog;
 
@@ -23,6 +26,8 @@ public class OrderReviewOrchestrationService : ITransient
         UserManager userManager,
         OrderWorkOrderGenerationService workOrderGen,
         OrderResourceCheckService resourceCheck,
+        WorkOrderMaterialDetailSyncService materialDetailSync,
+        WorkOrderRoutingSyncService routingSync,
         S1MdpSyncTransformService mdpSync,
         AidopActionRunLogWriter runLog)
     {
@@ -30,6 +35,8 @@ public class OrderReviewOrchestrationService : ITransient
         _userManager = userManager;
         _workOrderGen = workOrderGen;
         _resourceCheck = resourceCheck;
+        _materialDetailSync = materialDetailSync;
+        _routingSync = routingSync;
         _mdpSync = mdpSync;
         _runLog = runLog;
     }
@@ -58,6 +65,9 @@ public class OrderReviewOrchestrationService : ITransient
                 var check = await _resourceCheck.RunForEntryAsync(order, entry, wo.WorkOrd, account, warnings);
                 result.ResourceCheckCount++;
                 result.ResourceCheckLineCount += check.LineCount;
+
+                await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, wo.WorkOrd, account);
+                await _routingSync.EnsureFromRoutingAsync(entry.TenantId, wo.WorkOrd, account);
             }
 
             await UpdateEntriesProgressAsync(entries.Select(e => e.Id).ToList(), "0", account);
@@ -209,8 +219,6 @@ public class OrderReviewOrchestrationService : ITransient
         if (entries.Count == 0)
             throw Oops.Oh($"订单 {order.BillNo} 没有可评审的明细行(须为新建,评审,再评审状态)");
 
-        var reviewedEntryIds = new List<long>();
-
         foreach (var entry in entries)
         {
             ValidateEntryForResourceCheck(entry);
@@ -231,6 +239,8 @@ public class OrderReviewOrchestrationService : ITransient
                 result.WorkOrders.Add(wo.WorkOrd);
 
                 await _resourceCheck.WriteResultAsync(order, entry, wo.WorkOrd, lines, account);
+                await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, wo.WorkOrd, account);
+                await _routingSync.EnsureFromRoutingAsync(entry.TenantId, wo.WorkOrd, account);
                 warnings.Add($"订单行 {entry.EntrySeq} 存在缺料(工单 {wo.WorkOrd})");
             }
             else
@@ -244,12 +254,9 @@ public class OrderReviewOrchestrationService : ITransient
                 }
             }
 
-            reviewedEntryIds.Add(entry.Id);
+            await UpdateEntryAfterReviewAsync(entry.Id, check.KittingTime, account);
         }
 
-        // 3. 所有明细行处理完毕后,批量更新 progress = '2'
-        await UpdateEntriesProgressAsync(reviewedEntryIds, "2", account);
-
         result.EntryCount = entries.Count;
         result.Message = $"订单 {order.BillNo} 评审完成(资源检查 {result.ResourceCheckCount} 条)";
     }
@@ -281,7 +288,26 @@ public class OrderReviewOrchestrationService : ITransient
             result.EntryCount++;
 
             if (woCnt > 0)
-                result.WorkOrders.Add(await LoadWorkOrdForEntryAsync(entry.TenantId, entry.Id));
+            {
+                var workOrd = await LoadWorkOrdForEntryAsync(entry.TenantId, entry.Id);
+                result.WorkOrders.Add(workOrd);
+
+                // 交期确认后将工单状态设为 p
+                if (!string.IsNullOrWhiteSpace(workOrd))
+                {
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE WorkOrdMaster
+                        SET Status = 'p', UpdateUser = @User, UpdateTime = @Now
+                        WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+                          AND IFNULL(TRIM(Status), '') = ''
+                        """,
+                        new SugarParameter("@TenantId", entry.TenantId),
+                        new SugarParameter("@WorkOrd", workOrd),
+                        new SugarParameter("@User", account),
+                        new SugarParameter("@Now", DateTime.Now));
+                }
+            }
             else
                 warnings.Add($"订单行 {entry.EntrySeq} 无活跃工单(库存可满足或已关闭),已直接确认交期");
         }
@@ -421,7 +447,7 @@ public class OrderReviewOrchestrationService : ITransient
             """
             UPDATE crm_seorderentry
             SET progress = '2',
-                sys_capacity_date = COALESCE(sys_capacity_date, @CapacityDate),
+                sys_capacity_date = @CapacityDate,
                 update_time = @Now
             WHERE Id = @Id AND IsDeleted = 0
             """,

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

@@ -203,7 +203,7 @@ public class OrderWorkOrderGenerationService : ITransient
                 CustNo, SalesJob, BusinessID, CreateUser, CreateTime, UpdateUser, UpdateTime,
                 IsActive, IsConfirm, tenant_id, Remark
             ) VALUES (
-                @Domain, @WorkOrd, @ItemNum, @ItemName, 'p', 'M',
+                @Domain, @WorkOrd, @ItemNum, @ItemName, '', 'M',
                 @Qty, 0, 0, @OrdDate, @DueDate, @Priority, @Urgent,
                 @CustNo, @SalesJob, @BusinessId, @User, @Now, @User, @Now,
                 1, 0, @TenantId, @Remark

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

@@ -1248,16 +1248,18 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
             await _db.Ado.ExecuteCommandAsync(
                 """
                 INSERT INTO srm_pr_main
-                (Id,pr_billno,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
+                (Id,pr_billno,pr_mono,entity_id,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
                  pr_rqty,pr_aqty,pr_sqty,icitem_id,icitem_name,pr_ssend_date,pr_sarrive_date,pr_unit,state,pr_type,currencytype,
                  create_by,create_by_name,create_time,update_by,update_by_name,update_time,tenant_id,factory_id,org_id,IsDeleted,company_id,IsRequireGoods,supplier_type)
                 VALUES
-                (@Id,@PrBillNo,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
+                (@Id,@PrBillNo,@PrMono,@EntityId,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
                  @PrRqty,@PrAqty,@PrSqty,@IcitemId,@IcitemName,@PrSsendDate,@PrSarriveDate,@PrUnit,@State,@PrType,@CurrencyType,
                  @CreateBy,@CreateByName,@CreateTime,@UpdateBy,@UpdateByName,@UpdateTime,@TenantId,@FactoryId,@OrgId,@IsDeleted,@CompanyId,@IsRequireGoods,@SupplierType)
                 """,
                 new SugarParameter("@Id", pr.Id),
                 new SugarParameter("@PrBillNo", pr.PrBillNo),
+                new SugarParameter("@PrMono", pr.PrMono ?? (object)DBNull.Value),
+                new SugarParameter("@EntityId", pr.EntityId ?? (object)DBNull.Value),
                 new SugarParameter("@PrPurchaseId", pr.PrPurchaseId),
                 new SugarParameter("@PrPurchaseNumber", pr.PrPurchaseNumber),
                 new SugarParameter("@PrPurchaseName", pr.PrPurchaseName),

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/Entity/PurchaseRequestMain.cs

@@ -15,6 +15,12 @@ public class PurchaseRequestMain
     [SugarColumn(ColumnName = "pr_billno", IsNullable = true)]
     public string? PrBillNo { get; set; }
 
+    [SugarColumn(ColumnName = "pr_mono", IsNullable = true)]
+    public string? PrMono { get; set; }
+
+    [SugarColumn(ColumnName = "entity_id", IsNullable = true)]
+    public long? EntityId { get; set; }
+
     [SugarColumn(ColumnName = "pr_purchaseid")]
     public long PrPurchaseId { get; set; }
 
@@ -101,4 +107,8 @@ public class PurchaseRequestMain
 
     [SugarColumn(ColumnName = "supplier_type", IsNullable = true)]
     public string? SupplierType { get; set; }
+
+    /// <summary>独立 PR 关联的合并 PR 编号(非数据库字段,仅内存传递用)。</summary>
+    [SugarColumn(IsIgnore = true)]
+    public string? IndividualPrReferBillNo { get; set; }
 }

+ 121 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/ProcurementPipelineService.cs

@@ -100,20 +100,53 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
         try
         {
             var newRequests = new List<PurchaseRequestMain>();
+            var deleteVouchers = new List<long>();
             if (createFromShortage)
             {
-                newRequests = await _shortageService.BuildFromResourceCheckAsync(tenantId, account);
-                result.ShortageItemCount = newRequests.Count;
+                var candidates = await _shortageService.BuildFromResourceCheckAsync(tenantId, account);
+                result.ShortageItemCount = candidates.Count;
+
+                // 幂等处理:对比已有 PR,更新/删除/保留
+                var (toAdd, toDelete) = await _shortageService.ReconcileAsync(tenantId, candidates, account);
+                newRequests = toAdd;
+                deleteVouchers = toDelete;
+                result.PrUpdatedCount = candidates.Count - toAdd.Count - toDelete.Count;
             }
 
             if (newRequests.Count > 0)
             {
-                var mergeNew = _mergeService.MergeGeneratedRequests(newRequests);
-                newRequests = mergeNew.Requests;
-                result.PrMergeReducedCount += mergeNew.ReducedCount;
-                await AssignPrNumbersAsync(newRequests, account);
-                await InsertPurchaseRequestsAsync(newRequests);
-                result.PrCreatedCount = newRequests.Count;
+                // 保留各工单独立 PR + 创建合并 PR(数量重新计算)
+                var split = await _mergeService.SplitMergeWithRecalcAsync(newRequests, account);
+
+                var individualPrs = split.IndividualRequests;
+                var mergedPrs = split.MergedRequests;
+
+                // 分配编号
+                await AssignPrNumbersAsync(individualPrs, account);
+                await AssignPrNumbersAsync(mergedPrs, account);
+
+                // 独立 PR: state=5(参考,不参与 DO/PO 转单)
+                foreach (var pr in individualPrs) pr.State = 5;
+                // 关联独立 PR 到合并 PR 编号
+                foreach (var group in individualPrs.GroupBy(x => new { x.IcitemId, x.PrPurchaseId }))
+                {
+                    var merged = mergedPrs.FirstOrDefault(m =>
+                        m.IcitemId == group.Key.IcitemId && m.PrPurchaseId == group.Key.PrPurchaseId);
+                    if (merged is not null)
+                    {
+                        foreach (var ind in group)
+                            ind.IndividualPrReferBillNo = merged.PrBillNo;
+                    }
+                }
+
+                // 插入独立 PR(参考)
+                await InsertIndividualPurchaseRequestsAsync(individualPrs);
+                // 插入合并 PR(可转单)
+                await InsertPurchaseRequestsAsync(mergedPrs);
+
+                result.PrCreatedCount = mergedPrs.Count;
+                result.IndividualPrCount = individualPrs.Count;
+                result.PrMergeReducedCount = Math.Max(0, individualPrs.Count - mergedPrs.Count);
             }
 
             if (mergeHistorical)
@@ -124,6 +157,24 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
                 result.PrCreatedCount += historicalMerge.CreatedPrCount;
             }
 
+            // 删除已无缺料的旧 PR
+            if (deleteVouchers.Count > 0)
+            {
+                foreach (var v in deleteVouchers)
+                {
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE srm_pr_main
+                        SET IsDeleted = 1, update_by_name = @User, update_time = @Now
+                        WHERE voucher = @Voucher
+                        """,
+                        new SugarParameter("@User", account),
+                        new SugarParameter("@Now", DateTime.Now),
+                        new SugarParameter("@Voucher", v));
+                }
+                result.PrDeletedCount = deleteVouchers.Count;
+            }
+
             var pending = await LoadPendingPurchaseRequestsAsync(tenantId);
             result.PendingPrCount = pending.Count;
 
@@ -140,9 +191,6 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
 
             var pushCandidates = pending
                 .Where(x => x.IsRequireGoods == 0 && (x.State ?? 0) == 1)
-                .Concat(newRequests.Where(x => x.IsRequireGoods == 0))
-                .GroupBy(x => x.Id)
-                .Select(g => g.First())
                 .ToList();
 
             if (enableExternalPush && pushCandidates.Count > 0)
@@ -217,16 +265,18 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
             await _db.Ado.ExecuteCommandAsync(
                 """
                 INSERT INTO srm_pr_main
-                (Id,pr_billno,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
+                (Id,pr_billno,pr_mono,entity_id,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
                  pr_rqty,pr_aqty,pr_sqty,icitem_id,icitem_name,pr_ssend_date,pr_sarrive_date,pr_unit,state,pr_type,currencytype,
                  create_by_name,create_time,update_by_name,update_time,tenant_id,factory_id,org_id,IsDeleted,company_id,IsRequireGoods,supplier_type)
                 VALUES
-                (@Id,@PrBillNo,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
+                (@Id,@PrBillNo,@PrMono,@EntityId,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
                  @PrRqty,@PrAqty,@PrSqty,@IcitemId,@IcitemName,@PrSsendDate,@PrSarriveDate,@PrUnit,@State,@PrType,@CurrencyType,
                  @CreateByName,@CreateTime,@UpdateByName,@UpdateTime,@TenantId,@FactoryId,@OrgId,0,@CompanyId,@IsRequireGoods,@SupplierType)
                 """,
                 new SugarParameter("@Id", pr.Id),
                 new SugarParameter("@PrBillNo", pr.PrBillNo),
+                new SugarParameter("@PrMono", pr.PrMono ?? (object)DBNull.Value),
+                new SugarParameter("@EntityId", pr.EntityId ?? (object)DBNull.Value),
                 new SugarParameter("@PrPurchaseId", pr.PrPurchaseId),
                 new SugarParameter("@PrPurchaseNumber", pr.PrPurchaseNumber),
                 new SugarParameter("@PrPurchaseName", pr.PrPurchaseName),
@@ -256,6 +306,57 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
         }
     }
 
+    private async Task InsertIndividualPurchaseRequestsAsync(List<PurchaseRequestMain> requests)
+    {
+        foreach (var pr in requests)
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO srm_pr_main
+                (Id,pr_billno,pr_mono,entity_id,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
+                 pr_rqty,pr_aqty,pr_sqty,icitem_id,icitem_name,pr_ssend_date,pr_sarrive_date,pr_unit,state,pr_type,currencytype,
+                 create_by_name,create_time,update_by_name,update_time,tenant_id,factory_id,org_id,IsDeleted,company_id,
+                 IsRequireGoods,supplier_type,refer_pr_billno)
+                VALUES
+                (@Id,@PrBillNo,@PrMono,@EntityId,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
+                 @PrRqty,@PrAqty,@PrSqty,@IcitemId,@IcitemName,@PrSsendDate,@PrSarriveDate,@PrUnit,@State,@PrType,@CurrencyType,
+                 @CreateByName,@CreateTime,@UpdateByName,@UpdateTime,@TenantId,@FactoryId,@OrgId,0,@CompanyId,
+                 @IsRequireGoods,@SupplierType,@ReferPrBillNo)
+                """,
+                new SugarParameter("@Id", pr.Id),
+                new SugarParameter("@PrBillNo", pr.PrBillNo),
+                new SugarParameter("@PrMono", pr.PrMono ?? (object)DBNull.Value),
+                new SugarParameter("@EntityId", pr.EntityId ?? (object)DBNull.Value),
+                new SugarParameter("@PrPurchaseId", pr.PrPurchaseId),
+                new SugarParameter("@PrPurchaseNumber", pr.PrPurchaseNumber),
+                new SugarParameter("@PrPurchaseName", pr.PrPurchaseName),
+                new SugarParameter("@PrPurchaser", pr.PrPurchaser),
+                new SugarParameter("@PrPurchaserNum", pr.PrPurchaserNum),
+                new SugarParameter("@PrRqty", pr.PrRqty),
+                new SugarParameter("@PrAqty", pr.PrAqty),
+                new SugarParameter("@PrSqty", pr.PrSqty),
+                new SugarParameter("@IcitemId", pr.IcitemId),
+                new SugarParameter("@IcitemName", pr.IcitemName),
+                new SugarParameter("@PrSsendDate", pr.PrSsendDate),
+                new SugarParameter("@PrSarriveDate", pr.PrSarriveDate),
+                new SugarParameter("@PrUnit", pr.PrUnit),
+                new SugarParameter("@State", pr.State ?? 5),
+                new SugarParameter("@PrType", pr.PrType ?? 3),
+                new SugarParameter("@CurrencyType", pr.CurrencyType),
+                new SugarParameter("@CreateByName", pr.CreateByName),
+                new SugarParameter("@CreateTime", pr.CreateTime),
+                new SugarParameter("@UpdateByName", pr.UpdateByName),
+                new SugarParameter("@UpdateTime", pr.UpdateTime),
+                new SugarParameter("@TenantId", pr.TenantId),
+                new SugarParameter("@FactoryId", pr.FactoryId),
+                new SugarParameter("@OrgId", pr.OrgId),
+                new SugarParameter("@CompanyId", pr.CompanyId ?? 1000),
+                new SugarParameter("@IsRequireGoods", pr.IsRequireGoods),
+                new SugarParameter("@SupplierType", pr.SupplierType),
+                new SugarParameter("@ReferPrBillNo", pr.IndividualPrReferBillNo ?? (object)DBNull.Value));
+        }
+    }
+
     private async Task PersistPrStateUpdatesAsync(IReadOnlyList<long> prIds, int state, string account)
     {
         if (prIds.Count == 0) return;
@@ -303,7 +404,10 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
     private static string BuildMessage(ProcurementPipelineResult r)
     {
         var parts = new List<string>();
-        if (r.PrCreatedCount > 0) parts.Add($"新建 PR {r.PrCreatedCount}");
+        if (r.IndividualPrCount > 0) parts.Add($"独立 PR {r.IndividualPrCount}");
+        if (r.PrCreatedCount > 0) parts.Add($"合并 PR {r.PrCreatedCount}");
+        if (r.PrUpdatedCount > 0) parts.Add($"更新 PR {r.PrUpdatedCount}");
+        if (r.PrDeletedCount > 0) parts.Add($"删除 PR {r.PrDeletedCount}");
         if (r.PoCreatedCount > 0) parts.Add($"转 DO/PO {r.PoCreatedCount}");
         if (r.QadTrackingCount > 0) parts.Add($"外部推送 {r.QadTrackingCount}");
         return parts.Count > 0 ? string.Join(",", parts) : "无待处理采购申请";
@@ -313,7 +417,10 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
 public sealed class ProcurementPipelineResult
 {
     public int ShortageItemCount { get; set; }
+    public int IndividualPrCount { get; set; }
     public int PrCreatedCount { get; set; }
+    public int PrUpdatedCount { get; set; }
+    public int PrDeletedCount { get; set; }
     public int PrMergeReducedCount { get; set; }
     public int HistoricalPrMergedGroups { get; set; }
     public int PendingPrCount { get; set; }

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

@@ -18,14 +18,22 @@ public class PurchaseRequestFromShortageService : ITransient
             """
             SELECT
                 bce.item_number AS ItemNumber,
+                IFNULL(ber.morder_no, '') AS MorderNo,
+                IFNULL(ber.sentry_id, 0) AS SentryId,
                 SUM(IFNULL(bce.lack_qty, 0)) AS LackQty
             FROM b_examine_result ber
             INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.is_use = 1
+            INNER JOIN (
+                SELECT b2.morder_no, MAX(b2.Id) AS MaxId
+                FROM b_examine_result b2
+                WHERE b2.tenant_id = @TenantId AND b2.IsDeleted = 0
+                GROUP BY b2.morder_no
+            ) latest ON ber.Id = latest.MaxId
             WHERE ber.tenant_id = @TenantId
               AND ber.IsDeleted = 0
               AND IFNULL(bce.lack_qty, 0) > 0
               AND IFNULL(bce.erp_cls, 3) = 3
-            GROUP BY bce.item_number
+            GROUP BY bce.item_number, ber.morder_no, ber.sentry_id
             HAVING SUM(IFNULL(bce.lack_qty, 0)) > 0
             """,
             new SugarParameter("@TenantId", tenantId));
@@ -48,6 +56,8 @@ public class PurchaseRequestFromShortageService : ITransient
             result.Add(new PurchaseRequestMain
             {
                 Id = YitIdHelper.NextId(),
+                PrMono = string.IsNullOrWhiteSpace(row.MorderNo) ? null : row.MorderNo,
+                EntityId = row.SentryId > 0 ? row.SentryId : null,
                 PrPurchaseId = source.SupplierId,
                 PrPurchaseNumber = source.SupplierCode,
                 PrPurchaseName = source.SupplierName,
@@ -81,6 +91,108 @@ public class PurchaseRequestFromShortageService : ITransient
         return result;
     }
 
+    /// <summary>
+    /// 幂等处理:对比已有 PR 与本次计算的缺料,执行更新/删除/保留。
+    /// 返回 (新增候选, 需删除的 voucher 列表)。
+    /// </summary>
+    public async Task<(List<PurchaseRequestMain> NewCandidates, List<long> DeleteVouchers)> ReconcileAsync(
+        long tenantId, List<PurchaseRequestMain> newCandidates, string account)
+    {
+        if (newCandidates.Count == 0)
+            return (newCandidates, new List<long>());
+
+        // 1. 加载本次涉及的工单的已有 MRP PR(pr_type=3)
+        var workOrds = newCandidates
+            .Where(x => !string.IsNullOrWhiteSpace(x.PrMono))
+            .Select(x => x.PrMono!)
+            .Distinct()
+            .ToList();
+
+        if (workOrds.Count == 0)
+            return (newCandidates, new List<long>());
+
+        var inClause = string.Join(",", workOrds.Select((_, i) => $"@Wo{i}"));
+        var existingPrs = await _db.Ado.SqlQueryAsync<ExistingPrRow>(
+            $"""
+            SELECT voucher, Id, pr_mono AS PrMono, icitem_id AS IcitemId,
+                   IFNULL(pr_rqty, 0) AS PrRqty, IFNULL(state, 0) AS State
+            FROM srm_pr_main
+            WHERE tenant_id = @TenantId AND IFNULL(IsDeleted, 0) = 0
+              AND IFNULL(pr_type, 0) = 3
+              AND pr_mono IN ({inClause})
+            """,
+            BuildWorkOrdParams(workOrds, tenantId));
+
+        if (existingPrs.Count == 0)
+            return (newCandidates, new List<long>());
+
+        var now = DateTime.Now;
+        var toDelete = new List<long>();
+        var matchedNew = new HashSet<int>();
+
+        // 2. 逐条已有 PR:匹配则更新数量,无匹配则标记删除
+        foreach (var existing in existingPrs)
+        {
+            var newIdx = newCandidates.FindIndex(x =>
+                !matchedNew.Contains(newCandidates.IndexOf(x))
+                && x.PrMono == existing.PrMono
+                && x.IcitemId == existing.IcitemId);
+
+            if (newIdx >= 0)
+            {
+                var newPr = newCandidates[newIdx];
+                matchedNew.Add(newIdx);
+
+                if (existing.PrRqty != newPr.PrRqty)
+                {
+                    await _db.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE srm_pr_main
+                        SET pr_rqty = @Rqty, pr_aqty = @Rqty, pr_sqty = @Rqty,
+                            update_by_name = @User, update_time = @Now
+                        WHERE voucher = @Voucher
+                        """,
+                        new SugarParameter("@Rqty", newPr.PrRqty),
+                        new SugarParameter("@User", account),
+                        new SugarParameter("@Now", now),
+                        new SugarParameter("@Voucher", existing.Voucher));
+                }
+            }
+            else
+            {
+                toDelete.Add(existing.Voucher);
+            }
+        }
+
+        // 3. 未匹配的 newCandidates 为真正的新增
+        var toAdd = new List<PurchaseRequestMain>();
+        for (var i = 0; i < newCandidates.Count; i++)
+        {
+            if (!matchedNew.Contains(i))
+                toAdd.Add(newCandidates[i]);
+        }
+
+        return (toAdd, toDelete);
+    }
+
+    private static List<SugarParameter> BuildWorkOrdParams(List<string> workOrds, long tenantId)
+    {
+        var ps = new List<SugarParameter> { new("@TenantId", tenantId) };
+        for (var i = 0; i < workOrds.Count; i++)
+            ps.Add(new SugarParameter($"@Wo{i}", workOrds[i]));
+        return ps;
+    }
+
+    private sealed class ExistingPrRow
+    {
+        public long Voucher { get; set; }
+        public long Id { get; set; }
+        public string? PrMono { get; set; }
+        public long IcitemId { get; set; }
+        public decimal PrRqty { get; set; }
+        public int State { get; set; }
+    }
+
     private async Task<SupplierSourceRow?> ResolveSupplierAsync(long tenantId, string itemNumber)
     {
         return (await _db.Ado.SqlQueryAsync<SupplierSourceRow>(
@@ -112,6 +224,8 @@ public class PurchaseRequestFromShortageService : ITransient
     private sealed class ShortageSourceRow
     {
         public string ItemNumber { get; set; } = string.Empty;
+        public string MorderNo { get; set; } = string.Empty;
+        public long SentryId { get; set; }
         public decimal LackQty { get; set; }
     }
 

+ 110 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseRequestMergeService.cs

@@ -204,6 +204,8 @@ public class PurchaseRequestMergeService : ITransient
         return new PurchaseRequestMain
         {
             Id = first.Id,
+            PrMono = first.PrMono,
+            EntityId = first.EntityId,
             PrPurchaseId = first.PrPurchaseId,
             PrPurchaseNumber = first.PrPurchaseNumber,
             PrPurchaseName = first.PrPurchaseName,
@@ -239,16 +241,18 @@ public class PurchaseRequestMergeService : ITransient
         await _db.Ado.ExecuteCommandAsync(
             """
             INSERT INTO srm_pr_main
-            (Id,pr_billno,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
+            (Id,pr_billno,pr_mono,entity_id,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
              pr_rqty,pr_aqty,pr_sqty,icitem_id,icitem_name,pr_ssend_date,pr_sarrive_date,pr_unit,state,pr_type,currencytype,
              create_by_name,create_time,update_by_name,update_time,tenant_id,factory_id,org_id,IsDeleted,company_id,IsRequireGoods,supplier_type)
             VALUES
-            (@Id,@PrBillNo,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
+            (@Id,@PrBillNo,@PrMono,@EntityId,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
              @PrRqty,@PrAqty,@PrSqty,@IcitemId,@IcitemName,@PrSsendDate,@PrSarriveDate,@PrUnit,@State,@PrType,@CurrencyType,
              @CreateByName,@CreateTime,@UpdateByName,@UpdateTime,@TenantId,@FactoryId,@OrgId,0,@CompanyId,@IsRequireGoods,@SupplierType)
             """,
             new SugarParameter("@Id", pr.Id),
             new SugarParameter("@PrBillNo", pr.PrBillNo),
+            new SugarParameter("@PrMono", pr.PrMono ?? (object)DBNull.Value),
+            new SugarParameter("@EntityId", pr.EntityId ?? (object)DBNull.Value),
             new SugarParameter("@PrPurchaseId", pr.PrPurchaseId),
             new SugarParameter("@PrPurchaseNumber", pr.PrPurchaseNumber),
             new SugarParameter("@PrPurchaseName", pr.PrPurchaseName),
@@ -284,6 +288,110 @@ public class PurchaseRequestMergeService : ITransient
         var diff = ((int)date.DayOfWeek + 6) % 7;
         return date.AddDays(-diff);
     }
+
+    /// <summary>
+    /// 保留各工单独立 PR + 创建合并 PR(数量 = 总需求 - 库存 - 在途)。
+    /// 仅当同组有 2 条以上 PR 时才创建合并 PR。
+    /// </summary>
+    public async Task<SplitMergeResult> SplitMergeWithRecalcAsync(
+        List<PurchaseRequestMain> requests, string account)
+    {
+        var result = new SplitMergeResult();
+        if (requests.Count == 0) return result;
+
+        var groups = requests
+            .GroupBy(x => new
+            {
+                x.TenantId,
+                x.CompanyId,
+                x.FactoryId,
+                x.IcitemId,
+                x.PrPurchaseId,
+                x.IsRequireGoods,
+                SupplierType = x.SupplierType ?? string.Empty,
+                WeekStart = GetWeekStart(x.PrSsendDate)
+            })
+            .ToList();
+
+        var now = DateTime.Now;
+        var mergedList = new List<PurchaseRequestMain>();
+
+        foreach (var group in groups)
+        {
+            var rows = group.OrderBy(x => x.PrSsendDate).ThenBy(x => x.PrSarriveDate).ThenBy(x => x.Id).ToList();
+
+            // ① 保留各工单独立 PR
+            result.IndividualRequests.AddRange(rows);
+
+            // ② 仅当同组有 2+ 条时才创建合并 PR
+            if (rows.Count >= 2)
+            {
+                var merged = BuildMergedRow(rows, account, now);
+
+                // 重新计算合并 PR 数量:总需求 - 库存 - 在途
+                var totalNeed = rows.Sum(x => x.PrRqty ?? 0);
+                var stock = await QueryStockQtyAsync(rows[0].IcitemId, rows[0].TenantId);
+                var transit = await QueryOpenPurchaseQtyAsync(rows[0].IcitemId, rows[0].TenantId);
+                var actualShortage = Math.Max(0, totalNeed - stock - transit);
+
+                merged.PrRqty = actualShortage;
+                merged.PrAqty = actualShortage;
+                merged.PrSqty = actualShortage;
+
+                mergedList.Add(merged);
+            }
+        }
+
+        result.MergedRequests = mergedList;
+        return result;
+    }
+
+    private async Task<decimal> QueryStockQtyAsync(long icitemId, long tenantId)
+    {
+        var itemNumber = await _db.Ado.GetStringAsync(
+            "SELECT number FROM ic_item WHERE Id = @Id LIMIT 1",
+            new SugarParameter("@Id", icitemId));
+        if (string.IsNullOrWhiteSpace(itemNumber)) return 0;
+
+        return 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", itemNumber),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    private async Task<decimal> QueryOpenPurchaseQtyAsync(long icitemId, long tenantId)
+    {
+        var itemNumber = await _db.Ado.GetStringAsync(
+            "SELECT number FROM ic_item WHERE Id = @Id LIMIT 1",
+            new SugarParameter("@Id", icitemId));
+        if (string.IsNullOrWhiteSpace(itemNumber)) return 0;
+
+        return await _db.Ado.GetDecimalAsync(
+            """
+            SELECT COALESCE(SUM(
+                GREATEST(IFNULL(d.QtyOrded, 0) - IFNULL(d.QtyReceived, 0), 0)
+            ), 0)
+            FROM PurOrdDetail d
+            INNER JOIN PurOrdMaster m ON m.RecID = d.PurOrdRecID
+            WHERE d.ItemNum = @ItemNum AND m.tenant_id = @TenantId
+              AND IFNULL(d.IsActive, 1) = 1 AND IFNULL(m.IsActive, 1) = 1
+            """,
+            new SugarParameter("@ItemNum", itemNumber),
+            new SugarParameter("@TenantId", tenantId));
+    }
+}
+
+public sealed class SplitMergeResult
+{
+    public List<PurchaseRequestMain> IndividualRequests { get; set; } = new();
+    public List<PurchaseRequestMain> MergedRequests { get; set; } = new();
 }
 
 public sealed class PurchaseRequestMergeResult

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

@@ -207,8 +207,6 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
             var account = _userManager.Account ?? "system";
 
             var kitting = await _kittingCheck.CheckSingleAsync(tid, wo, account);
-            if (!kitting.IsKitted)
-                throw Oops.Oh($"工单 {wo} 齐套检查未通过(缺料行 {kitting.ShortageLineCount}),请先完成资源检查或补料");
 
             WorkOrderPickBillService.PickBillResult pickResult;
             await _db.Ado.BeginTranAsync();

+ 90 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderRoutingSyncService.cs

@@ -0,0 +1,90 @@
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>从标准工艺路线同步工单工序明细(WorkOrdRouting)。</summary>
+public class WorkOrderRoutingSyncService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public WorkOrderRoutingSyncService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>
+    /// 确保工单有工艺路线数据;若已有活跃行则跳过。
+    /// 返回插入行数。
+    /// </summary>
+    public async Task<int> EnsureFromRoutingAsync(long tenantId, string workOrd, string account)
+    {
+        var existing = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(*) FROM WorkOrdRouting
+            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 createUser = account.Length > 24 ? account[..24] : account;
+
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO WorkOrdRouting (
+                `Descr`, `Domain`, `ChargeCode`, `Machine`, `RunCrew`, `MilestoneOp`, `OP`, `StdOp`, `ItemNum`, `WorkCtr`,
+                `Ufld1`, `Ufld3`, `Setup`, `MachinesperOp`, `Labor`, `RunTime`, `MachBdnRate`, `WorkCode`, `StdSetupTime`, `Engineer`,
+                `WorkOrd`, `WorkOrdMasterRecID`, `ERPfld1`, `QtyOrded`, `ProcessOut`, `ProcessOutDay`, `ProcessOutSupp`,
+                `IsActive`, `Status`, `ProdLine`, `CreateTime`, `CreateUser`, `tenant_id`, `CommentIndex`, `WaitTime`, `QtyComplete`, `ParentOp`
+            )
+            SELECT
+                a.`Descr`,
+                LEFT(TRIM(COALESCE(NULLIF(TRIM(IFNULL(w.`Domain`, '')), ''), NULLIF(TRIM(IFNULL(a.`Domain`, '')), ''), ' ')), 8),
+                '',
+                b.`InternalEquipmentCode`,
+                IFNULL(b.`StandardStaffCount`, 0),
+                CAST(IFNULL(a.`MilestoneOp`, 0) AS UNSIGNED),
+                IFNULL(a.`Op`, 0),
+                a.`StdOp`,
+                a.`RoutingCode`,
+                LEFT(IFNULL(b.`Site`, ''), 8),
+                '',
+                '',
+                IFNULL(a.`UDeci1`, 0),
+                IFNULL(a.`UDeci2`, 0),
+                IFNULL(a.`UDeci3`, 0),
+                IFNULL(a.`UDeci3`, 0) / 3600.0,
+                IFNULL(b.`Rate`, 0),
+                b.`OpType`,
+                IFNULL(b.`SetupTime`, 0),
+                b.`SkillNo`,
+                w.`WorkOrd`,
+                w.`RecID`,
+                w.`ERPfld1`,
+                IFNULL(w.`QtyOrded`, 0),
+                CAST(IFNULL(a.`UDeci5`, 0) AS SIGNED),
+                IFNULL(a.`ProcessOutDay`, 0),
+                a.`ProcessOutSupp`,
+                b'1',
+                'r',
+                LEFT(IFNULL(b.`Line`, ''), 8),
+                NOW(3),
+                @CreateUser,
+                w.`tenant_id`,
+                CAST(IFNULL(a.`MilestoneOp`, 0) AS UNSIGNED),
+                0, 0, 0
+            FROM WorkOrdMaster w
+            LEFT JOIN RoutingOpDetail a ON w.`ItemNum` = a.`RoutingCode`
+            LEFT JOIN ProdLineDetail b ON a.`RoutingCode` = b.`Part` AND a.`Op` = b.`Op`
+            WHERE w.`WorkOrd` = @WorkOrd
+              AND w.`tenant_id` = @TenantId
+              AND IFNULL(a.`IsActive`, 0) = 1
+              AND a.`MilestoneOp` IS NOT NULL
+            """,
+            new SugarParameter("@WorkOrd", workOrd),
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@CreateUser", createUser));
+
+        return affected;
+    }
+}