浏览代码

feat: 采购申请三类型订单分流(PO/DO/PW)+取消QadTracking统一转单+erp_cls扩大范围

Pengxy 6 小时之前
父节点
当前提交
96465ecb5a

+ 1 - 1
Web/package.json

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

+ 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.205</AssemblyVersion>
-    <FileVersion>1.0.205</FileVersion>
-    <Version>1.0.205</Version>
+    <AssemblyVersion>1.0.206</AssemblyVersion>
+    <FileVersion>1.0.206</FileVersion>
+    <Version>1.0.206</Version>
   </PropertyGroup>
 
   <ItemGroup>

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

@@ -261,7 +261,7 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
                 result.ExceptionCount,
                 result.PurchaseRequestCount,
                 result.PurchaseOrderCount,
-                result.QadTrackingCount
+                result.PurchaseOrderLineCount
             });
             return result;
         }
@@ -481,7 +481,6 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
         var purchaseRequests = new List<PurchaseRequestMain>();
         var prMergeReducedCount = 0;
         var purchaseOrderTransferResult = new PurchaseOrderTransferResult();
-        var externalPushResult = new PurchaseRequestExternalPushResult();
         var allocatedByPoLine = new Dictionary<string, decimal>();
         var skippedCount = 0;
 
@@ -596,14 +595,10 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
             {
                 await AssignPurchaseRequestNumbersAsync(purchaseRequests, account);
             }
-            if (rules.EnableRequireGoodsToPo && purchaseRequests.Any(x => x.IsRequireGoods == 1))
+            if (purchaseRequests.Count > 0)
             {
                 purchaseOrderTransferResult = await _purchaseOrderTransferService.TransferGeneratedRequireGoodsAsync(purchaseRequests, account);
             }
-            if (rules.EnableExternalPushTracking && purchaseRequests.Any(x => x.IsRequireGoods == 0))
-            {
-                externalPushResult = await _purchaseRequestExternalPushService.CreateQadTrackingForGeneratedRequestsAsync(purchaseRequests, account);
-            }
             if (schedules.Count > 0)
             {
                 var dsNumbersByKey = new Dictionary<string, string>();
@@ -654,13 +649,13 @@ public class DeliveryScheduleService : IDynamicApiController, ITransient
             PurchaseRequestMergeReducedCount = prMergeReducedCount,
             PurchaseOrderCount = purchaseOrderTransferResult.CreatedOrderCount,
             PurchaseOrderLineCount = purchaseOrderTransferResult.TransferredPrCount,
-            QadTrackingCount = externalPushResult.TrackingCount,
+
             SkippedCount = skippedCount,
             DsNums = schedules.Select(x => x.DsNum).Where(x => !string.IsNullOrWhiteSpace(x)).Take(20).ToList(),
             RuleSnapshot = ruleSnapshot,
             Message = schedules.Count > 0
-                ? $"生成交货单成功,共生成 {schedules.Count} 条;自动生成PR {purchaseRequests.Count} 条;合并减少 {prMergeReducedCount} 条;生成DO/PO {purchaseOrderTransferResult.CreatedOrderCount} 单;写QadTracking {externalPushResult.TrackingCount} 条;异常 {exceptions.Count} 条"
-                : $"未生成交货单,自动生成PR {purchaseRequests.Count} 条,合并减少 {prMergeReducedCount} 条,生成DO/PO {purchaseOrderTransferResult.CreatedOrderCount} 单,写QadTracking {externalPushResult.TrackingCount} 条,已记录异常 {exceptions.Count} 条"
+                ? $"生成交货单成功,共生成 {schedules.Count} 条;自动生成PR {purchaseRequests.Count} 条;合并减少 {prMergeReducedCount} 条;生成DO/PO {purchaseOrderTransferResult.CreatedOrderCount} 单;异常 {exceptions.Count} 条"
+                : $"未生成交货单,自动生成PR {purchaseRequests.Count} 条,合并减少 {prMergeReducedCount} 条,生成DO/PO {purchaseOrderTransferResult.CreatedOrderCount} 单,已记录异常 {exceptions.Count} 条"
         };
     }
 

+ 487 - 22
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/ProcurementPipelineService.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.ProcurementExecution;
+using Admin.NET.Plugin.AiDOP.WorkOrder;
 using Yitter.IdGenerator;
 
 namespace Admin.NET.Plugin.AiDOP.Supply;
@@ -49,7 +50,7 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
         _readPathCheck = readPathCheck;
     }
 
-    /// <summary>执行采购闭环(合并待处理 PR、转单、写 QadTracking)。</summary>
+    /// <summary>执行采购闭环(合并待处理 PR、转单)。</summary>
     [DisplayName("采购执行闭环")]
     [HttpPost("procurement/execute-pipeline")]
     public async Task<ProcurementPipelineResult> ExecutePipeline(
@@ -125,8 +126,8 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
                 await AssignPrNumbersAsync(individualPrs, account);
                 await AssignPrNumbersAsync(mergedPrs, account);
 
-                // 独立 PR: state=5(参考,不参与 DO/PO 转单)
-                foreach (var pr in individualPrs) pr.State = 5;
+                // 独立 PR: state=0(合并后旧PR关闭,不参与 DO/PO 转单)
+                foreach (var pr in individualPrs) pr.State = 0;
                 // 关联独立 PR 到合并 PR 编号
                 foreach (var group in individualPrs.GroupBy(x => new { x.IcitemId, x.PrPurchaseId }))
                 {
@@ -175,13 +176,15 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
                 result.PrDeletedCount = deleteVouchers.Count;
             }
 
+            // ── 生成物料交货计划(ic_demandschedule)──
+            result.DemandScheduleCount = await GenerateDemandScheduleAsync(tenantId, account);
+
             var pending = await LoadPendingPurchaseRequestsAsync(tenantId);
             result.PendingPrCount = pending.Count;
 
-            if (pending.Any(x => x.IsRequireGoods == 1))
+            if (pending.Count > 0)
             {
-                var transfer = await _transferService.TransferGeneratedRequireGoodsAsync(
-                    pending.Where(x => x.IsRequireGoods == 1).ToList(), account);
+                var transfer = await _transferService.TransferGeneratedRequireGoodsAsync(pending, account);
                 result.PoCreatedCount = transfer.CreatedOrderCount;
                 result.PoTransferredPrCount = transfer.TransferredPrCount;
                 result.PoOccupyRehangedCount = transfer.PoOccupyRehangedCount;
@@ -189,19 +192,6 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
                 await PersistPrStateUpdatesAsync(transfer.TransferredPrIds, 4, account);
             }
 
-            var pushCandidates = pending
-                .Where(x => x.IsRequireGoods == 0 && (x.State ?? 0) == 1)
-                .ToList();
-
-            if (enableExternalPush && pushCandidates.Count > 0)
-            {
-                var push = await _pushService.CreateQadTrackingForGeneratedRequestsAsync(pushCandidates, account);
-                result.QadTrackingCount = push.TrackingCount;
-                result.QadTrackingSkippedCount = push.SkippedCandidateCount;
-                result.Warnings.AddRange(push.Warnings);
-                await PersistPrStateUpdatesAsync(push.PushedPrIds, 2, account);
-            }
-
             await _db.Ado.CommitTranAsync();
         }
         catch
@@ -340,7 +330,7 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
                 new SugarParameter("@PrSsendDate", pr.PrSsendDate),
                 new SugarParameter("@PrSarriveDate", pr.PrSarriveDate),
                 new SugarParameter("@PrUnit", pr.PrUnit),
-                new SugarParameter("@State", pr.State ?? 5),
+                new SugarParameter("@State", pr.State ?? 0),
                 new SugarParameter("@PrType", pr.PrType ?? 3),
                 new SugarParameter("@CurrencyType", pr.CurrencyType),
                 new SugarParameter("@CreateByName", pr.CreateByName),
@@ -385,7 +375,7 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
     private async Task<List<string>> TryTriggerS4MdpRefreshAsync(ProcurementPipelineResult result)
     {
         var warnings = new List<string>();
-        if (result.PrCreatedCount == 0 && result.PoCreatedCount == 0 && result.QadTrackingCount == 0)
+        if (result.PrCreatedCount == 0 && result.PoCreatedCount == 0)
             return warnings;
 
         try
@@ -401,6 +391,479 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
         return warnings;
     }
 
+    /// <summary>
+    /// 生成物料交货计划(ic_demandschedule):按 8 周窗口汇总工单缺料与领料单待发料,
+    /// 扣减库存和已发布交货单在途后写入需求计划。
+    /// </summary>
+    private async Task<int> GenerateDemandScheduleAsync(long tenantId, string account)
+    {
+        var now = DateTime.Now;
+
+        // ── 1. 软删除旧未发布需求计划 + 禁用旧未发布交货单 + 标记历史版本 ──
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE ic_demandschedule
+            SET IsDeleted = 1, update_by_name = @User, update_time = @Now,
+                remarks = CONCAT(@NowStr, ',重新生成交货计划把未发布交货计划软删除')
+            WHERE tenant_id = @TenantId
+              AND IFNULL(IsDeleted, 0) = 0
+              AND IFNULL(ishistoryversion, 'N') <> 'Y'
+              AND IFNULL(status, '') <> 'P'
+            """,
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@NowStr", now.ToString("yyyy-MM-dd HH:mm:ss")),
+            new SugarParameter("@TenantId", tenantId));
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE srm_polist_ds
+            SET isactive = 0, updateuser = @User, updatetime = @Now,
+                remarks = CONCAT(@NowStr, ',重新生成交货计划把未发布交货单禁用')
+            WHERE tenant_id = @TenantId AND isactive = 1 AND IFNULL(status, 'N') = 'N'
+            """,
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@NowStr", now.ToString("yyyy-MM-dd HH:mm:ss")),
+            new SugarParameter("@TenantId", tenantId));
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE ic_demandschedule
+            SET ishistoryversion = 'Y', historyversionTime = @Now
+            WHERE tenant_id = @TenantId
+              AND IFNULL(IsDeleted, 0) = 0
+              AND IFNULL(ishistoryversion, 'N') <> 'Y'
+            """,
+            new SugarParameter("@Now", now),
+            new SugarParameter("@TenantId", tenantId));
+
+        // ── 2. 计算 8 周窗口(从下周一开始) ──
+        var weekday = (int)now.DayOfWeek;
+        var addDays = weekday == 0 ? 1 : 8 - weekday;
+        var beginTime = now.Date.AddDays(addDays);
+        var endTime = beginTime.AddDays(21 + 28 + 6).Add(new TimeSpan(23, 59, 59));
+
+        // ── 3. 加载窗口内工单(Status 为空或 P) ──
+        var workOrds = await _db.Ado.SqlQueryAsync<WorkOrdRow>(
+            """
+            SELECT WorkOrd, OrdDate, Status
+            FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId
+              AND OrdDate >= @Begin AND OrdDate <= @End
+              AND (Status IS NULL OR UPPER(Status) = 'P')
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@Begin", beginTime),
+            new SugarParameter("@End", endTime));
+
+        // ── 4. 加载已下达/投产/暂停工单(提前开工,用于领料单) ──
+        var pickBillWorkOrds = await _db.Ado.SqlQueryAsync<string>(
+            """
+            SELECT DISTINCT WorkOrd
+            FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId
+              AND Status IS NOT NULL AND UPPER(Status) NOT IN ('C', 'P')
+            """,
+            new SugarParameter("@TenantId", tenantId));
+
+        var allWorkOrdList = workOrds.Select(w => w.WorkOrd!).ToList();
+        var pickBillWorkOrdList = pickBillWorkOrds.ToList();
+        allWorkOrdList = allWorkOrdList.Union(pickBillWorkOrdList).Distinct().ToList();
+
+        if (allWorkOrdList.Count == 0)
+            return 0;
+
+        // ── 5. 加载每个工单的最新资源检查结果 ──
+        var examineResults = await _db.Ado.SqlQueryAsync<ExamineResultRow>(
+            """
+            SELECT ber.morder_no AS MorderNo, MAX(ber.Id) AS ExamineId, MAX(ber.bangid) AS BangId
+            FROM b_examine_result ber
+            WHERE ber.tenant_id = @TenantId AND ber.IsDeleted = 0
+              AND ber.morder_no IN (@WorkOrds)
+            GROUP BY ber.morder_no
+            """.Replace("@WorkOrds", string.Join(",", allWorkOrdList.Select((_, i) => $"@Wo{i}"))),
+            BuildInParams(allWorkOrdList, tenantId));
+
+        var examineIds = examineResults.Select(e => e.ExamineId).ToList();
+
+        // ── 6. 加载 BOM 子件检查行(erp_cls=3 外购 或 2 委外) ──
+        var examines = examineIds.Count == 0
+            ? new List<BomChildExamineRow>()
+            : await _db.Ado.SqlQueryAsync<BomChildExamineRow>(
+                """
+                SELECT item_number AS ItemNumber, examine_id AS ExamineId,
+                       IFNULL(lack_qty, 0) AS LackQty, IFNULL(needCount, 0) AS NeedCount,
+                       tenant_id AS TenantId, company_id AS CompanyId
+                FROM b_bom_child_examine
+                WHERE tenant_id = @TenantId AND is_use = 1
+                  AND (erp_cls = 3 OR erp_cls = 2)
+                  AND examine_id IN (@ExamineIds)
+                """.Replace("@ExamineIds", string.Join(",", examineIds.Select((_, i) => $"@Ei{i}"))),
+                BuildExamineParams(examineIds, tenantId));
+
+        // ── 7. 加载领料单待发料明细(NbrDetail Type=SM, QtyOrd-QtyRec>0, Status!=C) ──
+        var pickBills = await _db.Ado.SqlQueryAsync<PickBillRow>(
+            """
+            SELECT ItemNum, QtyOrd, QtyRec
+            FROM NbrDetail
+            WHERE tenant_id = @TenantId AND UPPER(Type) = 'SM'
+              AND (QtyOrd - QtyRec) > 0 AND UPPER(IFNULL(Status, '')) <> 'C'
+              AND WorkOrd IN (@PickWorkOrds)
+            """.Replace("@PickWorkOrds", string.Join(",", pickBillWorkOrdList.Select((_, i) => $"@Pw{i}"))),
+            BuildInParams(pickBillWorkOrdList, tenantId, "Pw"));
+
+        // ── 8. 加载已发布交货单在途(srm_polist_ds isactive=1, status='P') ──
+        var publishedDsList = await _db.Ado.SqlQueryAsync<PublishedDsRow>(
+            """
+            SELECT itemnum, IFNULL(SUM(schedqty - sentqty), 0) AS PendingQty
+            FROM srm_polist_ds
+            WHERE tenant_id = @TenantId AND isactive = 1 AND IFNULL(status, 'N') = 'P'
+            GROUP BY itemnum
+            """,
+            new SugarParameter("@TenantId", tenantId));
+
+        // ── 9. 收集所有涉及物料编号 ──
+        var items = examines.Select(e => e.ItemNumber!).Distinct().ToList();
+        items.AddRange(pickBills.Select(p => p.ItemNum!));
+        items = items.Distinct().ToList();
+
+        if (items.Count == 0)
+            return 0;
+
+        // ── 10. 加载物料主数据 ──
+        var itemList = await _db.Ado.SqlQueryAsync<ItemMasterRow>(
+            """
+            SELECT ItemNum, IFNULL(Rev, '') AS Rev, IFNULL(Drawing, '') AS Drawing,
+                   IFNULL(InsLT, 0) AS InsLT, IFNULL(MFGMTTR, 0) AS MFGMTTR
+            FROM ItemMaster
+            WHERE tenant_id = @TenantId AND ItemNum IN (@Items)
+            """.Replace("@Items", string.Join(",", items.Select((_, i) => $"@It{i}"))),
+            BuildInParams(items, tenantId, "It"));
+
+        // ── 11. 加载库存(InvMaster,按配置的库位范围) ──
+        var locationList = new List<string> { "1001", "5007", "5008", "8000", "8001" };
+        var locationInClause = string.Join(",", locationList.Select(l => $"'{l}'"));
+
+        var stockRows = await _db.Ado.SqlQueryAsync<StockRow>(
+            $"""
+            SELECT ItemNum,
+                   IFNULL(SUM(IFNULL(AvailStatusQty, 0) + IFNULL(Assay, 0)), 0) AS Qty
+            FROM InvMaster
+            WHERE ItemNum IN ({string.Join(",", items.Select((_, i) => $"@Sk{i}"))})
+              AND IsActive = 1
+              AND Location IN ({locationInClause})
+              AND tenant_id = @TenantId
+            GROUP BY ItemNum
+            """,
+            BuildInParams(items, tenantId, "Sk"));
+
+        var weekStockQty = stockRows.ToDictionary(s => s.ItemNum!, s => s.Qty, StringComparer.OrdinalIgnoreCase);
+        var weekDsQty = publishedDsList.ToDictionary(d => d.ItemNum!, d => d.PendingQty, StringComparer.OrdinalIgnoreCase);
+
+        // ── 12. 按 8 周循环生成需求计划 ──
+        var dsRecords = new List<DemandScheduleInsertRow>();
+        var remainingPickBills = pickBills.ToList();
+
+        for (var i = 0; i < 8; i++)
+        {
+            var itemBegin = beginTime.AddDays(i * 7);
+            var itemEnd = endTime.AddDays(7 * i - 21 - 28);
+            var weekWorkOrds = workOrds.Where(a => a.OrdDate >= itemBegin && a.OrdDate <= itemEnd).ToList();
+            var itemQtyList = new List<DemandItemDto>();
+
+            if (weekWorkOrds.Count > 0)
+            {
+                foreach (var wo in weekWorkOrds)
+                {
+                    // 已下达工单(R)的缺料已计入领料单,不在此重复
+                    if (!string.IsNullOrEmpty(wo.Status) && wo.Status.ToUpper() == "R")
+                        continue;
+
+                    var exam = examineResults.FirstOrDefault(e => e.MorderNo == wo.WorkOrd);
+                    if (exam is null || exam.ExamineId <= 0)
+                        continue;
+
+                    var itemLackList = examines.Where(a => a.ExamineId == exam.ExamineId).ToList();
+                    foreach (var lack in itemLackList)
+                    {
+                        var existing = itemQtyList.FirstOrDefault(x => x.ItemNum == lack.ItemNumber);
+                        if (existing != null)
+                        {
+                            existing.LackQty += lack.LackQty;
+                            existing.NeedQty += lack.NeedCount;
+                            if (!existing.WorkOrds.Contains(wo.WorkOrd!))
+                                existing.WorkOrds += "," + wo.WorkOrd;
+                            if (!existing.BangId.Contains(exam.BangId?.ToString() ?? ""))
+                                existing.BangId += "," + (exam.BangId?.ToString() ?? "");
+                        }
+                        else
+                        {
+                            itemQtyList.Add(new DemandItemDto
+                            {
+                                ItemNum = lack.ItemNumber!,
+                                LackQty = lack.LackQty,
+                                NeedQty = lack.NeedCount,
+                                WorkOrds = wo.WorkOrd!,
+                                BangId = exam.BangId?.ToString() ?? ""
+                            });
+                        }
+                    }
+                }
+
+                var requestDate = weekWorkOrds.Min(a => a.OrdDate) ?? DateTime.Today;
+
+                foreach (var d in itemQtyList)
+                {
+                    var im = itemList.FirstOrDefault(a => a.ItemNum == d.ItemNum);
+                    var arrivalDate = requestDate.AddDays(-1).AddDays(-(im?.InsLT ?? 0)).AddDays(-(im?.MFGMTTR ?? 0));
+
+                    decimal mesQty = d.NeedQty;
+                    // 第一周:工单需求 + 已下达工单领料单待发料(只算一次)
+                    if (i == 0)
+                    {
+                        var pickQty = remainingPickBills.Where(p => p.ItemNum == d.ItemNum).Sum(q => q.QtyOrd - q.QtyRec);
+                        mesQty = d.NeedQty + pickQty;
+                        remainingPickBills.RemoveAll(p => p.ItemNum == d.ItemNum);
+                    }
+
+                    // 库存扣减
+                    decimal stockDeduction = 0;
+                    weekStockQty.TryGetValue(d.ItemNum, out var stock);
+                    var locQty = stock;
+                    if (mesQty >= locQty)
+                    {
+                        weekStockQty[d.ItemNum] = 0;
+                        stockDeduction = locQty;
+                    }
+                    else
+                    {
+                        weekStockQty[d.ItemNum] = locQty - mesQty;
+                        stockDeduction = mesQty;
+                    }
+
+                    // 交货单在途扣减
+                    decimal sechedQty = 0;
+                    if (weekDsQty.TryGetValue(d.ItemNum, out var dsPending))
+                    {
+                        sechedQty = dsPending;
+                        if (mesQty - stockDeduction - sechedQty >= 0)
+                            weekDsQty[d.ItemNum] = 0;
+                        else
+                            weekDsQty[d.ItemNum] = dsPending - (mesQty - stockDeduction);
+                    }
+
+                    var toSechedQty = mesQty - (locQty + sechedQty);
+                    dsRecords.Add(new DemandScheduleInsertRow
+                    {
+                        ItemNum = d.ItemNum,
+                        FVersion = im?.Rev ?? "",
+                        Drawing = im?.Drawing ?? "",
+                        RequestDate = requestDate,
+                        ArrivalDate = arrivalDate,
+                        ShortQty = d.LackQty,
+                        MesQty = mesQty,
+                        LocQty = locQty,
+                        SechedQty = sechedQty,
+                        ToSechedQty = toSechedQty,
+                        WoList = d.WorkOrds,
+                        BangId = d.BangId
+                    });
+                }
+            }
+
+            // 第一周额外处理:领料单中剩余的物料(不在资源检查缺料中的已下达工单待发料)
+            if (i == 0 && remainingPickBills.Count > 0)
+            {
+                var pickbillItems = remainingPickBills.Select(a => a.ItemNum!).Distinct().ToList();
+                var requestDate = itemBegin;
+                foreach (var item in pickbillItems)
+                {
+                    var im = itemList.FirstOrDefault(a => a.ItemNum == item);
+                    if (im is null) continue;
+
+                    var arrivalDate = requestDate.AddDays(-1).AddDays(-im.InsLT).AddDays(-im.MFGMTTR);
+                    var mesQty = remainingPickBills.Where(p => p.ItemNum == item).Sum(q => q.QtyOrd - q.QtyRec);
+
+                    decimal stockDeduction = 0;
+                    weekStockQty.TryGetValue(item, out var stock);
+                    var locQty = stock;
+                    if (mesQty >= locQty)
+                    {
+                        weekStockQty[item] = 0;
+                        stockDeduction = locQty;
+                    }
+                    else
+                    {
+                        weekStockQty[item] = locQty - mesQty;
+                        stockDeduction = mesQty;
+                    }
+
+                    decimal sechedQty = 0;
+                    if (weekDsQty.TryGetValue(item, out var dsPending))
+                    {
+                        sechedQty = dsPending;
+                        if (mesQty - stockDeduction - sechedQty >= 0)
+                            weekDsQty[item] = 0;
+                        else
+                            weekDsQty[item] = dsPending - (mesQty - stockDeduction);
+                    }
+
+                    var toSechedQty = mesQty - (locQty + sechedQty);
+                    dsRecords.Add(new DemandScheduleInsertRow
+                    {
+                        ItemNum = item,
+                        FVersion = im.Rev,
+                        Drawing = im.Drawing,
+                        RequestDate = requestDate,
+                        ArrivalDate = arrivalDate,
+                        ShortQty = 0,
+                        MesQty = mesQty,
+                        LocQty = locQty,
+                        SechedQty = sechedQty,
+                        ToSechedQty = toSechedQty,
+                        WoList = "",
+                        BangId = ""
+                    });
+                }
+            }
+        }
+
+        // ── 13. 批量插入 ic_demandschedule ──
+        foreach (var ds in dsRecords)
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ic_demandschedule
+                (Id, itemnum, fversion, drawing, requestdate, arrivaldate,
+                 shortqty, mesqty, locqty, sechedqty, tosechedqty,
+                 status, remarks, ishistoryversion,
+                 create_by_name, create_time, update_by_name, update_time,
+                 tenant_id, factory_id, org_id, company_id, IsDeleted,
+                 wolist, bangid)
+                VALUES
+                (@Id, @ItemNum, @FVersion, @Drawing, @RequestDate, @ArrivalDate,
+                 @ShortQty, @MesQty, @LocQty, @SechedQty, @ToSechedQty,
+                 '', '', 'N',
+                 @User, @Now, @User, @Now,
+                 @TenantId, @TenantId, @TenantId, 1000, 0,
+                 @WoList, @BangId)
+                """,
+                new SugarParameter("@Id", YitIdHelper.NextId()),
+                new SugarParameter("@ItemNum", ds.ItemNum),
+                new SugarParameter("@FVersion", ds.FVersion ?? ""),
+                new SugarParameter("@Drawing", ds.Drawing ?? ""),
+                new SugarParameter("@RequestDate", ds.RequestDate),
+                new SugarParameter("@ArrivalDate", ds.ArrivalDate),
+                new SugarParameter("@ShortQty", ds.ShortQty),
+                new SugarParameter("@MesQty", ds.MesQty),
+                new SugarParameter("@LocQty", ds.LocQty),
+                new SugarParameter("@SechedQty", ds.SechedQty),
+                new SugarParameter("@ToSechedQty", ds.ToSechedQty),
+                new SugarParameter("@User", account),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@WoList", ds.WoList ?? ""),
+                new SugarParameter("@BangId", ds.BangId ?? ""));
+        }
+
+        return dsRecords.Count;
+    }
+
+    private static List<SugarParameter> BuildInParams(List<string> values, long tenantId, string prefix = "Wo")
+    {
+        var ps = new List<SugarParameter> { new("@TenantId", tenantId) };
+        for (var i = 0; i < values.Count; i++)
+            ps.Add(new SugarParameter($"@{prefix}{i}", values[i]));
+        return ps;
+    }
+
+    private static List<SugarParameter> BuildExamineParams(List<long> ids, long tenantId)
+    {
+        var ps = new List<SugarParameter> { new("@TenantId", tenantId) };
+        for (var i = 0; i < ids.Count; i++)
+            ps.Add(new SugarParameter($"@Ei{i}", ids[i]));
+        return ps;
+    }
+
+    // ── DTO for demand schedule generation ──
+    private sealed class WorkOrdRow
+    {
+        public string? WorkOrd { get; set; }
+        public DateTime? OrdDate { get; set; }
+        public string? Status { get; set; }
+    }
+
+    private sealed class ExamineResultRow
+    {
+        public string? MorderNo { get; set; }
+        public long ExamineId { get; set; }
+        public long? BangId { get; set; }
+    }
+
+    private sealed class BomChildExamineRow
+    {
+        public string? ItemNumber { get; set; }
+        public long? ExamineId { get; set; }
+        public decimal LackQty { get; set; }
+        public decimal NeedCount { get; set; }
+        public long TenantId { get; set; }
+        public long CompanyId { get; set; }
+    }
+
+    private sealed class PickBillRow
+    {
+        public string? ItemNum { get; set; }
+        public decimal QtyOrd { get; set; }
+        public decimal QtyRec { get; set; }
+    }
+
+    private sealed class PublishedDsRow
+    {
+        public string? ItemNum { get; set; }
+        public decimal PendingQty { get; set; }
+    }
+
+    private sealed class ItemMasterRow
+    {
+        public string? ItemNum { get; set; }
+        public string? Rev { get; set; }
+        public string? Drawing { get; set; }
+        public int InsLT { get; set; }
+        public int MFGMTTR { get; set; }
+    }
+
+    private sealed class StockRow
+    {
+        public string? ItemNum { get; set; }
+        public decimal Qty { get; set; }
+    }
+
+    private sealed class DemandItemDto
+    {
+        public string ItemNum { get; set; } = string.Empty;
+        public decimal LackQty { get; set; }
+        public decimal NeedQty { get; set; }
+        public string WorkOrds { get; set; } = string.Empty;
+        public string BangId { get; set; } = string.Empty;
+    }
+
+    private sealed class DemandScheduleInsertRow
+    {
+        public string? ItemNum { get; set; }
+        public string? FVersion { get; set; }
+        public string? Drawing { get; set; }
+        public DateTime RequestDate { get; set; }
+        public DateTime ArrivalDate { get; set; }
+        public decimal ShortQty { get; set; }
+        public decimal MesQty { get; set; }
+        public decimal LocQty { get; set; }
+        public decimal SechedQty { get; set; }
+        public decimal ToSechedQty { get; set; }
+        public string? WoList { get; set; }
+        public string? BangId { get; set; }
+    }
+
     private static string BuildMessage(ProcurementPipelineResult r)
     {
         var parts = new List<string>();
@@ -409,7 +872,8 @@ public class ProcurementPipelineService : IDynamicApiController, ITransient
         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}");
+
+        if (r.DemandScheduleCount > 0) parts.Add($"交货计划 {r.DemandScheduleCount}");
         return parts.Count > 0 ? string.Join(",", parts) : "无待处理采购申请";
     }
 }
@@ -429,6 +893,7 @@ public sealed class ProcurementPipelineResult
     public int PoOccupyRehangedCount { get; set; }
     public int QadTrackingCount { get; set; }
     public int QadTrackingSkippedCount { get; set; }
+    public int DemandScheduleCount { get; set; }
     public List<string> CreatedPoNumbers { get; set; } = new();
     public List<string> Warnings { get; set; } = new();
     public bool S4MdpRefreshed { get; set; }

+ 138 - 45
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseOrderTransferService.cs

@@ -1,7 +1,7 @@
 namespace Admin.NET.Plugin.AiDOP.Supply;
 
 /// <summary>
-/// 采购申请转 DO/PO 服务。对齐旧 PrAutoApprove:处理待处理要货令 PR,生成 PurOrdMaster/Detail 并改挂 srm_po_occupy。
+/// 采购申请转 PO 服务。处理待处理 PR,生成 PurOrdMaster/Detail 并改挂 srm_po_occupy。
 /// </summary>
 public class PurchaseOrderTransferService : ITransient
 {
@@ -19,8 +19,7 @@ public class PurchaseOrderTransferService : ITransient
     public async Task<PurchaseOrderTransferResult> TransferGeneratedRequireGoodsAsync(List<PurchaseRequestMain> requests, string account)
     {
         var candidates = requests
-            .Where(x => x.IsRequireGoods == 1
-                        && (x.State ?? 0) == 1
+            .Where(x => (x.State ?? 0) == 1
                         && !string.IsNullOrWhiteSpace(x.PrBillNo))
             .ToList();
         if (candidates.Count == 0) return new PurchaseOrderTransferResult();
@@ -37,60 +36,74 @@ public class PurchaseOrderTransferService : ITransient
                 x.PrPurchaseId,
                 x.PrPurchaseNumber,
                 x.PrPurchaseName,
+                x.IsRequireGoods,
                 SupplierType = x.SupplierType ?? string.Empty
             })
             .ToList();
 
-        foreach (var tenantGroup in groups.GroupBy(x => x.Key.TenantId.ToString()))
+        foreach (var group in groups)
         {
-            var groupList = tenantGroup.ToList();
-            var purOrdNumbers = await _numberRuleService.NextBatchInCurrentTransactionAsync("DO", tenantGroup.Key, groupList.Count, account);
-            if (purOrdNumbers.Count < groupList.Count || purOrdNumbers.Any(string.IsNullOrWhiteSpace))
-                throw Oops.Oh($"当前DO/PO单号生成失败,请检查DO编号规则维护。Domain={tenantGroup.Key}");
+            var now = DateTime.Now;
+            var rows = group.OrderBy(x => x.PrSarriveDate).ThenBy(x => x.PrBillNo).ToList();
 
-            for (var i = 0; i < groupList.Count; i++)
+            var supplier = await LoadSupplierContextAsync(group.Key.PrPurchaseId, rows[0].IcitemId, group.Key.TenantId);
+            var supplierCode = supplier?.SupplierNumber ?? group.Key.PrPurchaseNumber;
+            var supplierType = string.IsNullOrWhiteSpace(group.Key.SupplierType)
+                ? supplier?.SupplierType ?? string.Empty
+                : group.Key.SupplierType;
+            var isOutsource = string.Equals(supplierType, "委外", StringComparison.OrdinalIgnoreCase);
+            var isRequireGoods = group.Key.IsRequireGoods == 1;
+
+            // 三类型分流:IsRequireGoods=0→PO(采购订单);IsRequireGoods=1+委外→PW(委外加工订单);IsRequireGoods=1+非委外→DO(要货令)
+            string ruleCode, poType, reqBy, usage;
+            if (!isRequireGoods)
+            {
+                ruleCode = "PO"; poType = "po"; reqBy = "PO"; usage = supplierType;
+            }
+            else if (isOutsource)
             {
-                var group = groupList[i];
-                var purOrd = purOrdNumbers[i].Trim();
-                var now = DateTime.Now;
-                var rows = group.OrderBy(x => x.PrSarriveDate).ThenBy(x => x.PrBillNo).ToList();
+                ruleCode = "PW"; poType = "PW"; reqBy = "DO"; usage = "委外加工";
+            }
+            else
+            {
+                ruleCode = "DO"; poType = "po"; reqBy = "DO"; usage = supplierType;
+            }
+            var buyer = ResolveBuyer(supplierType);
 
-                var supplier = await LoadSupplierContextAsync(group.Key.PrPurchaseId, rows[0].IcitemId, group.Key.TenantId);
-                var supplierCode = supplier?.SupplierNumber ?? group.Key.PrPurchaseNumber;
-                var supplierType = string.IsNullOrWhiteSpace(group.Key.SupplierType)
-                    ? supplier?.SupplierType ?? string.Empty
-                    : group.Key.SupplierType;
-                var isOutsource = string.Equals(supplierType, "委外", StringComparison.OrdinalIgnoreCase);
-                var poType = isOutsource ? "PW" : "po";
-                var usage = isOutsource ? "委外加工" : supplierType;
-                var buyer = ResolveBuyer(supplierType);
+            var purOrdNumbers = await _numberRuleService.NextBatchInCurrentTransactionAsync(
+                ruleCode, group.Key.TenantId.ToString(), 1, account);
+            if (purOrdNumbers.Count == 0 || string.IsNullOrWhiteSpace(purOrdNumbers[0]))
+                throw Oops.Oh($"当前{ruleCode}单号生成失败,请检查{ruleCode}编号规则维护。Domain={group.Key.TenantId}");
+            var purOrd = purOrdNumbers[0].Trim();
 
-                await InsertPurchaseOrderMasterAsync(purOrd, poType, usage, buyer, supplierCode, now, account, group.Key.TenantId);
-                var masterId = await _db.Ado.GetIntAsync(
-                    "SELECT IFNULL(MAX(RecID),0) FROM PurOrdMaster WHERE PurOrd=@PurOrd",
-                    new SugarParameter("@PurOrd", purOrd));
-                if (masterId <= 0) throw Oops.Oh("DO/PO主表生成失败。");
+            await InsertPurchaseOrderMasterAsync(purOrd, poType, reqBy, usage, buyer, supplierCode, now, account, group.Key.TenantId);
+            var masterId = await _db.Ado.GetIntAsync(
+                "SELECT IFNULL(MAX(RecID),0) FROM PurOrdMaster WHERE PurOrd=@PurOrd",
+                new SugarParameter("@PurOrd", purOrd));
+            if (masterId <= 0) throw Oops.Oh("采购订单主表生成失败。");
 
-                for (var lineIndex = 0; lineIndex < rows.Count; lineIndex++)
-                {
-                    var pr = rows[lineIndex];
-                    var line = lineIndex + 1;
-                    await InsertPurchaseOrderDetailAsync(purOrd, poType, masterId, line, pr, account);
-                    var detailRecId = await _db.Ado.GetIntAsync(
-                        "SELECT IFNULL(MAX(RecID),0) FROM PurOrdDetail WHERE PurOrd=@PurOrd AND Line=@Line",
-                        new SugarParameter("@PurOrd", purOrd),
-                        new SugarParameter("@Line", line));
-                    if (detailRecId > 0)
-                        poOccupyRehangedCount += await RehangPoOccupyFromPrToDetailAsync(pr.Id, detailRecId, pr.TenantId, account);
+            for (var lineIndex = 0; lineIndex < rows.Count; lineIndex++)
+            {
+                var pr = rows[lineIndex];
+                var line = lineIndex + 1;
+                var (detailRecId, itemNum) = await InsertPurchaseOrderDetailAsync(purOrd, poType, masterId, line, pr, account);
+                if (detailRecId > 0)
+                    poOccupyRehangedCount += await RehangPoOccupyFromPrToDetailAsync(pr.Id, detailRecId, pr.TenantId, account);
 
-                    pr.State = 4;
-                    pr.UpdateByName = account;
-                    pr.UpdateTime = now;
-                    transferredPrIds.Add(pr.Id);
+                // 委外加工订单:BOM展开写入PurOrdDetailBatch
+                if (isOutsource && detailRecId > 0 && !string.IsNullOrWhiteSpace(itemNum))
+                {
+                    await InsertPurOrdDetailBatchAsync(purOrd, poType, line, detailRecId, itemNum,
+                        pr.PrAqty ?? pr.PrSqty ?? pr.PrRqty ?? 0, pr.TenantId, account, now);
                 }
 
-                createdOrders.Add(purOrd);
+                pr.State = 4;
+                pr.UpdateByName = account;
+                pr.UpdateTime = now;
+                transferredPrIds.Add(pr.Id);
             }
+
+            createdOrders.Add(purOrd);
         }
 
         return new PurchaseOrderTransferResult
@@ -145,6 +158,7 @@ public class PurchaseOrderTransferService : ITransient
     private async Task InsertPurchaseOrderMasterAsync(
         string purOrd,
         string poType,
+        string reqBy,
         string? usage,
         string buyer,
         string? supplierCode,
@@ -171,11 +185,12 @@ public class PurchaseOrderTransferService : ITransient
                 0, 0, 0, 0, 0, 1,
                 0, 0, 0, 0, 1, 1, @PoType, 0,
                 1, 0, 0,
-                @Buyer, @Domain, @PurOrd, @Now, 'DO', '', @Supp, @CreateUser, @Now,
+                @Buyer, @Domain, @PurOrd, @Now, @ReqBy, '', @Supp, @CreateUser, @Now,
                 @UpdateUser, @Now, @Usage, @FSTID, @Typed, @TenantId
             )
             """,
             new SugarParameter("@PoType", poType),
+            new SugarParameter("@ReqBy", reqBy),
             new SugarParameter("@Buyer", buyer),
             new SugarParameter("@Domain", tenantId.ToString()),
             new SugarParameter("@PurOrd", purOrd),
@@ -189,7 +204,8 @@ public class PurchaseOrderTransferService : ITransient
             new SugarParameter("@TenantId", tenantId <= 0 ? null : tenantId));
     }
 
-    private async Task InsertPurchaseOrderDetailAsync(string purOrd, string poType, int masterId, int line, PurchaseRequestMain pr, string account)
+    private async Task<(int detailRecId, string itemNum)> InsertPurchaseOrderDetailAsync(
+        string purOrd, string poType, int masterId, int line, PurchaseRequestMain pr, string account)
     {
         var item = (await _db.Ado.SqlQueryAsync<ItemLookupRow>(
             """
@@ -252,6 +268,74 @@ public class PurchaseOrderTransferService : ITransient
             new SugarParameter("@UpdateUser", account),
             new SugarParameter("@Now", DateTime.Now),
             new SugarParameter("@TenantId", pr.TenantId <= 0 ? null : pr.TenantId));
+
+        var detailRecId = await _db.Ado.GetIntAsync(
+            "SELECT IFNULL(MAX(RecID),0) FROM PurOrdDetail WHERE PurOrd=@PurOrd AND Line=@Line",
+            new SugarParameter("@PurOrd", purOrd),
+            new SugarParameter("@Line", line));
+
+        return (detailRecId, item?.ItemNum ?? pr.IcitemName ?? string.Empty);
+    }
+
+    /// <summary>委外加工订单BOM展开:查询ProductStructureMaster并写入PurOrdDetailBatch。</summary>
+    private async Task InsertPurOrdDetailBatchAsync(
+        string purOrd, string poType, int line, int detailRecId, string parentItem,
+        decimal prQty, long tenantId, string account, DateTime now)
+    {
+        var components = await _db.Ado.SqlQueryAsync<BomComponentRow>(
+            """
+            SELECT
+                psm.ComponentItem AS SuppItem,
+                CAST(psm.Qty AS DECIMAL(18,5)) AS QtyPerUnit,
+                IFNULL(psm.UM, im.UM) AS UM,
+                IFNULL(im.ItemNum, psm.ComponentItem) AS ItemNum,
+                IFNULL(im.Location, '') AS Location
+            FROM ProductStructureMaster psm
+            LEFT JOIN ItemMaster im ON psm.ComponentItem = im.ItemNum
+            WHERE psm.ParentItem = @ParentItem
+            ORDER BY psm.ComponentItem
+            """,
+            new SugarParameter("@ParentItem", parentItem));
+
+        var batchNo = 1;
+        foreach (var comp in components)
+        {
+            if (string.IsNullOrWhiteSpace(comp.SuppItem)) continue;
+            var batchQty = Math.Round(prQty * comp.QtyPerUnit, 5);
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO PurOrdDetailBatch
+                (
+                    Domain, PurOrd, Potype, Line, Batch, ItemNum, SuppItem, UM, Location,
+                    QtyOrded, QtyBO, QtyReleased, QtyReceived, QtyReturned, LotSerial, PurOrdDetailRecID,
+                    CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
+                )
+                VALUES
+                (
+                    @Domain, @PurOrd, @Potype, @Line, @Batch, @ItemNum, @SuppItem, @UM, @Location,
+                    @QtyOrded, @QtyBO, 0, 0, 0, '', @PurOrdDetailRecID,
+                    @CreateUser, @CreateTime, @UpdateUser, @UpdateTime, @TenantId
+                )
+                """,
+                new SugarParameter("@Domain", tenantId.ToString()),
+                new SugarParameter("@PurOrd", purOrd),
+                new SugarParameter("@Potype", poType),
+                new SugarParameter("@Line", line),
+                new SugarParameter("@Batch", batchNo),
+                new SugarParameter("@ItemNum", comp.ItemNum ?? comp.SuppItem),
+                new SugarParameter("@SuppItem", comp.SuppItem),
+                new SugarParameter("@UM", comp.UM ?? string.Empty),
+                new SugarParameter("@Location", string.IsNullOrWhiteSpace(comp.Location) ? "1001" : comp.Location),
+                new SugarParameter("@QtyOrded", batchQty),
+                new SugarParameter("@QtyBO", batchQty),
+                new SugarParameter("@PurOrdDetailRecID", detailRecId),
+                new SugarParameter("@CreateUser", account),
+                new SugarParameter("@CreateTime", now),
+                new SugarParameter("@UpdateUser", account),
+                new SugarParameter("@UpdateTime", now),
+                new SugarParameter("@TenantId", tenantId <= 0 ? null : tenantId));
+            batchNo++;
+        }
     }
 
     private static string ResolveBuyer(string? supplierType)
@@ -279,6 +363,15 @@ public class PurchaseOrderTransferService : ITransient
         public string? Rev { get; set; }
         public string? Drawing { get; set; }
     }
+
+    private sealed class BomComponentRow
+    {
+        public string? SuppItem { get; set; }
+        public decimal QtyPerUnit { get; set; }
+        public string? UM { get; set; }
+        public string? ItemNum { get; set; }
+        public string? Location { get; set; }
+    }
 }
 
 public sealed class PurchaseOrderTransferResult

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

@@ -32,7 +32,7 @@ public class PurchaseRequestFromShortageService : ITransient
             WHERE ber.tenant_id = @TenantId
               AND ber.IsDeleted = 0
               AND IFNULL(bce.lack_qty, 0) > 0
-              AND IFNULL(bce.erp_cls, 3) = 3
+              AND IFNULL(bce.erp_cls, 3) IN (2, 3)
             GROUP BY bce.item_number, ber.morder_no, ber.sentry_id
             HAVING SUM(IFNULL(bce.lack_qty, 0)) > 0
             """,