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; /// /// S4 采购执行闭环:缺料生成 PR → 合并 → 要货令转 DO/PO → 外部推送日志。 /// 对应旧系统 AutoTransferDoOrPo / AutomaticPrAdjustDate 业务意图。 /// [ApiDescriptionSettings(Order = 309, Description = "采购执行闭环")] [Route("api/Supply")] [AllowAnonymous] [NonUnify] public class ProcurementPipelineService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; private readonly UserManager _userManager; private readonly PurchaseRequestFromShortageService _shortageService; private readonly PurchaseRequestMergeService _mergeService; private readonly NumberRuleService _numberRuleService; private readonly PurchaseOrderTransferService _transferService; private readonly PurchaseRequestExternalPushService _pushService; private readonly AidopActionRunLogWriter _runLog; private readonly S4MdpSyncTransformService _s4MdpSync; private readonly S4ReadPathConsistencyService _readPathCheck; public ProcurementPipelineService( ISqlSugarClient db, UserManager userManager, PurchaseRequestFromShortageService shortageService, PurchaseRequestMergeService mergeService, NumberRuleService numberRuleService, PurchaseOrderTransferService transferService, PurchaseRequestExternalPushService pushService, AidopActionRunLogWriter runLog, S4MdpSyncTransformService s4MdpSync, S4ReadPathConsistencyService readPathCheck) { _db = db; _userManager = userManager; _shortageService = shortageService; _mergeService = mergeService; _numberRuleService = numberRuleService; _transferService = transferService; _pushService = pushService; _runLog = runLog; _s4MdpSync = s4MdpSync; _readPathCheck = readPathCheck; } /// 执行采购闭环(合并待处理 PR、转单)。 [DisplayName("采购执行闭环")] [HttpPost("procurement/execute-pipeline")] public async Task ExecutePipeline( [FromQuery] string? domain, [FromQuery] bool createFromShortage = true, [FromQuery] bool mergeHistorical = true, [FromQuery] bool enableExternalPush = true) { var tenantId = ResolveTenantId(domain); var account = _userManager.Account ?? "system"; var logId = await _runLog.StartAsync("S4_PROCUREMENT_PIPELINE", tenantId, "tenant", tenantId, domain ?? tenantId.ToString()); try { var result = await ExecuteCoreAsync(tenantId, account, createFromShortage, mergeHistorical, enableExternalPush); result.ReadPathConsistency = await _readPathCheck.CheckTenantAsync(tenantId); result.Warnings.AddRange(await TryTriggerS4MdpRefreshAsync(result)); await _runLog.SuccessAsync(logId, result.Message, result); return result; } catch (Exception ex) { await _runLog.FailedAsync(logId, ex.Message, new { tenantId, domain }); throw Oops.Oh(ex.Message); } } /// S4 读路径一致性检查(运行表 vs DWD)。 [DisplayName("S4读路径一致性检查")] [HttpGet("procurement/read-path-consistency")] public async Task ReadPathConsistency([FromQuery] string? domain) { var tenantId = ResolveTenantId(domain); return await _readPathCheck.CheckTenantAsync(tenantId); } public async Task ExecuteCoreAsync( long tenantId, string account, bool createFromShortage, bool mergeHistorical = true, bool enableExternalPush = true) { var result = new ProcurementPipelineResult(); var now = DateTime.Now; await _db.Ado.BeginTranAsync(); try { var newRequests = new List(); var deleteVouchers = new List(); if (createFromShortage) { 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) { // 保留各工单独立 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=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 })) { 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) { var historicalMerge = await _mergeService.MergeTenantPendingAsync(tenantId, account); result.PrMergeReducedCount += historicalMerge.ReducedCount; result.HistoricalPrMergedGroups = historicalMerge.MergedGroupCount; 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; } // ── 生成物料交货计划(ic_demandschedule)── result.DemandScheduleCount = await GenerateDemandScheduleAsync(tenantId, account); var pending = await LoadPendingPurchaseRequestsAsync(tenantId); result.PendingPrCount = pending.Count; if (pending.Count > 0) { var transfer = await _transferService.TransferGeneratedRequireGoodsAsync(pending, account); result.PoCreatedCount = transfer.CreatedOrderCount; result.PoTransferredPrCount = transfer.TransferredPrCount; result.PoOccupyRehangedCount = transfer.PoOccupyRehangedCount; result.CreatedPoNumbers = transfer.CreatedOrders; await PersistPrStateUpdatesAsync(transfer.TransferredPrIds, 4, account); } await _db.Ado.CommitTranAsync(); } catch { await _db.Ado.RollbackTranAsync(); throw; } result.Message = BuildMessage(result); return result; } private async Task> LoadPendingPurchaseRequestsAsync(long tenantId) { var windowEnd = DateTime.Today.AddDays(35); var rows = await _db.Ado.SqlQueryAsync( """ SELECT Id, pr_billno AS PrBillNo, pr_purchaseid AS PrPurchaseId, pr_purchasenumber AS PrPurchaseNumber, pr_purchasename AS PrPurchaseName, pr_purchaser AS PrPurchaser, pr_purchaser_num AS PrPurchaserNum, pr_rqty AS PrRqty, pr_aqty AS PrAqty, pr_sqty AS PrSqty, icitem_id AS IcitemId, icitem_name AS IcitemName, pr_ssend_date AS PrSsendDate, pr_sarrive_date AS PrSarriveDate, pr_unit AS PrUnit, state AS State, pr_type AS PrType, currencytype AS CurrencyType, tenant_id AS TenantId, factory_id AS FactoryId, org_id AS OrgId, company_id AS CompanyId, IsRequireGoods, supplier_type AS SupplierType, IsDeleted FROM srm_pr_main WHERE tenant_id = @TenantId AND IFNULL(IsDeleted, 0) = 0 AND IFNULL(state, 0) = 1 AND IFNULL(analogcalcversion, '') = '' AND pr_ssend_date IS NOT NULL AND pr_ssend_date <= @WindowEnd ORDER BY pr_ssend_date, Id """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WindowEnd", windowEnd)); return rows; } private async Task AssignPrNumbersAsync(List requests, string account) { foreach (var group in requests.GroupBy(x => x.TenantId.ToString())) { var rows = group.ToList(); var numbers = await _numberRuleService.NextBatchInCurrentTransactionAsync("PR", group.Key, rows.Count, account); if (numbers.Count < rows.Count) throw Oops.Oh("采购申请编号生成失败"); for (var i = 0; i < rows.Count; i++) rows[i].PrBillNo = numbers[i].Trim(); } } private async Task InsertPurchaseRequestsAsync(List 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) 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) """, 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 ?? 1), 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)); } } private async Task InsertIndividualPurchaseRequestsAsync(List 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 ?? 0), 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 prIds, int state, string account) { if (prIds.Count == 0) return; foreach (var id in prIds.Distinct()) { await _db.Ado.ExecuteCommandAsync( """ UPDATE srm_pr_main SET state = @State, update_by_name = @User, update_time = @Now WHERE Id = @Id """, new SugarParameter("@State", state), new SugarParameter("@User", account), new SugarParameter("@Now", DateTime.Now), new SugarParameter("@Id", id)); } } private long ResolveTenantId(string? domain) { if (!string.IsNullOrWhiteSpace(domain) && long.TryParse(domain.Trim(), out var tid) && tid > 0) return tid; return AidopTenantHelper.Resolve(App.HttpContext); } private async Task> TryTriggerS4MdpRefreshAsync(ProcurementPipelineResult result) { var warnings = new List(); if (result.PrCreatedCount == 0 && result.PoCreatedCount == 0) return warnings; try { var mdp = await _s4MdpSync.RunFullAsync(triggerType: "PROCUREMENT_PIPELINE"); result.S4MdpRefreshed = true; result.S4MdpBatchId = mdp.BatchId; } catch (Exception ex) { warnings.Add($"S4 MDP/DWD 刷新未完成:{ex.Message}"); } return warnings; } /// /// 生成物料交货计划(ic_demandschedule):按 8 周窗口汇总工单缺料与领料单待发料, /// 扣减库存和已发布交货单在途后写入需求计划。 /// private async Task 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( """ 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( """ 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( """ 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() : await _db.Ado.SqlQueryAsync( """ 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( """ 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( """ 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( """ 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 { "1001", "5007", "5008", "8000", "8001" }; var locationInClause = string.Join(",", locationList.Select(l => $"'{l}'")); var stockRows = await _db.Ado.SqlQueryAsync( $""" 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(); 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(); 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 BuildInParams(List values, long tenantId, string prefix = "Wo") { var ps = new List { new("@TenantId", tenantId) }; for (var i = 0; i < values.Count; i++) ps.Add(new SugarParameter($"@{prefix}{i}", values[i])); return ps; } private static List BuildExamineParams(List ids, long tenantId) { var ps = new List { 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(); 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.DemandScheduleCount > 0) parts.Add($"交货计划 {r.DemandScheduleCount}"); return parts.Count > 0 ? string.Join(",", parts) : "无待处理采购申请"; } } 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; } public int PoCreatedCount { get; set; } public int PoTransferredPrCount { get; set; } public int PoOccupyRehangedCount { get; set; } public int QadTrackingCount { get; set; } public int QadTrackingSkippedCount { get; set; } public int DemandScheduleCount { get; set; } public List CreatedPoNumbers { get; set; } = new(); public List Warnings { get; set; } = new(); public bool S4MdpRefreshed { get; set; } public string? S4MdpBatchId { get; set; } public S4ReadPathConsistencyResult? ReadPathConsistency { get; set; } public string Message { get; set; } = string.Empty; }