using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Production; using Admin.NET.Plugin.AiDOP.Supply; using Admin.NET.Plugin.AiDOP.WorkOrder; namespace Admin.NET.Plugin.AiDOP.Order; /// /// S1 订单评审编排:资源检查、状态更新、工单生成、运行日志。 /// public class OrderReviewOrchestrationService : ITransient { public const string ActionReview = "S1_ORDER_REVIEW"; public const string ActionConfirm = "S1_DELIVERY_CONFIRM"; public const string ActionRefresh = "S1_ORDER_REFRESH_PLAN"; /// 订单评审资源检查批次ID,用于跨工单库存占用递减。 private const long ReviewBangId = 2; private readonly ISqlSugarClient _db; 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; private readonly ProductionScheduleGenerationService _scheduleGen; private readonly ProcurementPipelineService _pipeline; public OrderReviewOrchestrationService( ISqlSugarClient db, UserManager userManager, OrderWorkOrderGenerationService workOrderGen, OrderResourceCheckService resourceCheck, WorkOrderMaterialDetailSyncService materialDetailSync, WorkOrderRoutingSyncService routingSync, S1MdpSyncTransformService mdpSync, AidopActionRunLogWriter runLog, ProductionScheduleGenerationService scheduleGen, ProcurementPipelineService pipeline) { _db = db; _userManager = userManager; _workOrderGen = workOrderGen; _resourceCheck = resourceCheck; _materialDetailSync = materialDetailSync; _routingSync = routingSync; _mdpSync = mdpSync; _runLog = runLog; _scheduleGen = scheduleGen; _pipeline = pipeline; } public Task ReviewAsync(IReadOnlyList orderIds) => ExecuteBatchAsync(ActionReview, orderIds, ReviewOneOrderAsync); public Task ConfirmDeliveryAsync(IReadOnlyList orderIds) => ExecuteBatchAsync(ActionConfirm, orderIds, ConfirmOneOrderAsync); public Task RefreshPlanAsync(long orderId, string? reason) => ExecuteSingleAsync(ActionRefresh, orderId, async (order, result, warnings, account) => { // ── 第0步:物料编码变更校验 ── await ValidateMaterialNotChangedAsync(order.Id, order.TenantId); // ── 第1步:清理旧占用记录,确保跨工单库存递减重新计算 ── await _db.Ado.ExecuteCommandAsync( "DELETE FROM ic_item_stockoccupy WHERE tenant_id = @TenantId AND bang_id = @BangId", new SugarParameter("@TenantId", order.TenantId), new SugarParameter("@BangId", ReviewBangId)); await _db.Ado.ExecuteCommandAsync( "DELETE FROM srm_po_occupy WHERE tenant_id = @TenantId AND bang_id = @BangId", new SugarParameter("@TenantId", order.TenantId), new SugarParameter("@BangId", ReviewBangId)); // ── 第2步:加载可重排明细行(确认 / 再评审) ── var entries = await LoadReviewableEntriesAsync(order.Id, order.TenantId, ["3", "0"]); if (entries.Count == 0) throw Oops.Oh("订单没有可重排的确认/再评审明细行"); // ── 第3步:逐条处理(工单 + 资源检查 + 领料单 + 交期更新) ── foreach (var entry in entries) { ValidateEntryForResourceCheck(entry); var wo = await _workOrderGen.CreateOrUpdateForEntryAsync(order, entry, account, warnings); if (wo.Created) result.WorkOrderCreatedCount++; else result.WorkOrderUpdatedCount++; result.WorkOrders.Add(wo.WorkOrd); var check = await _resourceCheck.RunForEntryAsync(order, entry, wo.WorkOrd, account, warnings, ReviewBangId); result.ResourceCheckCount++; result.ResourceCheckLineCount += check.LineCount; await _materialDetailSync.EnsureFromResourceCheckAsync(entry.TenantId, wo.WorkOrd, account); await _routingSync.EnsureFromRoutingAsync(entry.TenantId, wo.WorkOrd, account); // 当工单状态为下达/投产/暂停(R、W、S)时,更新对应领料单数据 await UpdatePickingListForActiveWorkOrderAsync(entry.TenantId, wo.WorkOrd, account, warnings); // 根据资源检查结果更新明细行系统建议交期 await UpdateEntrySysCapacityDateAsync(entry.Id, check.KittingTime, account); } // ── 第4步:更新明细行进度为3 ── await UpdateEntriesProgressAsync(entries.Select(e => e.Id).ToList(), "3", account); // ── 第4.5步:重新进行生产排程(在事务提交前) ── try { var scheduleResult = await _scheduleGen.GenerateAsync(order.TenantId, order.TenantId.ToString(), account); if (!string.IsNullOrWhiteSpace(scheduleResult.Message)) warnings.Add($"生产排程:{scheduleResult.Message}"); } catch (Exception ex) { warnings.Add($"生产排程失败:{ex.Message}"); } // ── 第4.5步:同步物料需求(MRP → PR → 采购闭环) ── try { var mrResult = await _pipeline.ExecuteCoreAsync(order.TenantId, account, createFromShortage: true); if (!string.IsNullOrWhiteSpace(mrResult.Message)) warnings.Add($"物料需求同步:{mrResult.Message}"); } catch (Exception ex) { warnings.Add($"物料需求同步失败:{ex.Message}"); } // ── 第4.5步:生成交货单(出货计划) ── await GenerateShippingPlanFromOrderAsync(order, entries, account, warnings); result.EntryCount = entries.Count; result.Message = string.IsNullOrWhiteSpace(reason) ? "3级计划重排完成" : $"3级计划重排完成:{reason.Trim()}"; }); private async Task ExecuteBatchAsync( string actionCode, IReadOnlyList orderIds, Func, string, Task> handler) { if (orderIds is null || orderIds.Count == 0) throw Oops.Oh("至少选择一条订单"); var tenantId = _userManager.TenantId > 0 ? _userManager.TenantId : AidopTenantHelper.Resolve(App.HttpContext); var account = _userManager.Account ?? "system"; var distinctIds = orderIds.Distinct().ToList(); var aggregate = new SeOrderReviewExecuteResult { ActionCode = actionCode, OrderCount = distinctIds.Count, Message = "执行成功" }; var allWarnings = new List(); long? firstLogId = null; // 评审/重排前清理旧占用记录,确保跨工单库存递减正确 if (actionCode == ActionReview || actionCode == ActionRefresh) { await _db.Ado.ExecuteCommandAsync( "DELETE FROM ic_item_stockoccupy WHERE tenant_id = @TenantId AND bang_id = @BangId", new SugarParameter("@TenantId", tenantId), new SugarParameter("@BangId", ReviewBangId)); await _db.Ado.ExecuteCommandAsync( "DELETE FROM srm_po_occupy WHERE tenant_id = @TenantId AND bang_id = @BangId", new SugarParameter("@TenantId", tenantId), new SugarParameter("@BangId", ReviewBangId)); } foreach (var orderId in distinctIds) { var order = await LoadOrderAsync(orderId, tenantId) ?? throw Oops.Oh($"订单 {orderId} 不存在或不属于当前租户"); var runLogId = await _runLog.StartAsync(actionCode, tenantId, "crm_seorder", order.Id, order.BillNo); if (firstLogId is null) firstLogId = runLogId; var perOrder = new SeOrderReviewExecuteResult { ActionCode = actionCode }; var warnings = new List(); try { await _db.Ado.BeginTranAsync(); await handler(order, perOrder, warnings, account); await _db.Ado.CommitTranAsync(); aggregate.EntryCount += perOrder.EntryCount; aggregate.WorkOrderCreatedCount += perOrder.WorkOrderCreatedCount; aggregate.WorkOrderUpdatedCount += perOrder.WorkOrderUpdatedCount; aggregate.WorkOrderClosedCount += perOrder.WorkOrderClosedCount; aggregate.ResourceCheckCount += perOrder.ResourceCheckCount; aggregate.ResourceCheckLineCount += perOrder.ResourceCheckLineCount; aggregate.WorkOrders.AddRange(perOrder.WorkOrders); allWarnings.AddRange(warnings); await _runLog.SuccessAsync(runLogId, perOrder.Message, new { orderId = order.Id, billNo = order.BillNo, perOrder.EntryCount, perOrder.WorkOrderCreatedCount, perOrder.WorkOrderUpdatedCount, workOrders = perOrder.WorkOrders, warnings }); } catch (Exception ex) { await _db.Ado.RollbackTranAsync(); await _runLog.FailedAsync(runLogId, ex.Message, new { orderId = order.Id, billNo = order.BillNo }); throw Oops.Oh(ex.Message); } } aggregate.RunLogId = firstLogId ?? 0; aggregate.Warnings = allWarnings.Distinct().ToList(); aggregate.Message = BuildAggregateMessage(actionCode, aggregate); if (actionCode == ActionReview && aggregate.ResourceCheckCount > 0) aggregate.Warnings.AddRange(await TryTriggerMdpRefreshAsync()); return aggregate; } private async Task ExecuteSingleAsync( string actionCode, long orderId, Func, string, Task> handler) { var tenantId = _userManager.TenantId > 0 ? _userManager.TenantId : AidopTenantHelper.Resolve(App.HttpContext); var account = _userManager.Account ?? "system"; var order = await LoadOrderAsync(orderId, tenantId) ?? throw Oops.Oh("订单不存在或不属于当前租户"); var result = new SeOrderReviewExecuteResult { ActionCode = actionCode, OrderCount = 1 }; var warnings = new List(); var runLogId = await _runLog.StartAsync(actionCode, tenantId, "crm_seorder", order.Id, order.BillNo); result.RunLogId = runLogId; try { await _db.Ado.BeginTranAsync(); await handler(order, result, warnings, account); await _db.Ado.CommitTranAsync(); result.Warnings = warnings; result.Message = BuildSingleMessage(result); await _runLog.SuccessAsync(runLogId, result.Message, new { orderId = order.Id, billNo = order.BillNo, result.EntryCount, result.WorkOrderCreatedCount, result.WorkOrderUpdatedCount, workOrders = result.WorkOrders, warnings }); if (actionCode == ActionRefresh && result.ResourceCheckCount > 0) result.Warnings.AddRange(await TryTriggerMdpRefreshAsync()); return result; } catch (Exception ex) { await _db.Ado.RollbackTranAsync(); await _runLog.FailedAsync(runLogId, ex.Message, new { orderId = order.Id, billNo = order.BillNo }); throw Oops.Oh(ex.Message); } } private async Task ReviewOneOrderAsync( OrderWorkOrderGenerationService.OrderHeader order, SeOrderReviewExecuteResult result, List warnings, string account) { var entries = await LoadReviewableEntriesAsync(order.Id, order.TenantId, ["1", "0", "2"]); if (entries.Count == 0) throw Oops.Oh($"订单 {order.BillNo} 没有可评审的明细行(须为新建,评审,再评审状态)"); foreach (var entry in entries) { ValidateEntryForResourceCheck(entry); if (entry.PlanDate is null) throw Oops.Oh($"订单行 {entry.EntrySeq} 缺少客户要求交期(plan_date)"); // 1. 先做资源检查(纯 BOM 展开 + 库存计算,不依赖工单),记录库存占用 var (check, lines) = await _resourceCheck.CheckOnlyAsync(order, entry, warnings, ReviewBangId); result.ResourceCheckCount++; result.ResourceCheckLineCount += check.LineCount; // 2. 有缺料 → 才生成工单并写入资源检查结果;库存可满足则跳过工单 if (check.HasShortage) { var wo = await _workOrderGen.CreateOrUpdateForEntryAsync(order, entry, account, warnings); if (wo.Created) result.WorkOrderCreatedCount++; else result.WorkOrderUpdatedCount++; 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 { // 3. 不缺料 → 若之前已生成工单则关闭(Status='C', IsActive=0) var closedCount = await CloseExistingWorkOrdersAsync(entry.TenantId, entry.Id, account); if (closedCount > 0) { result.WorkOrderClosedCount += closedCount; warnings.Add($"订单行 {entry.EntrySeq} 库存可满足,已关闭 {closedCount} 个历史工单"); } } await UpdateEntryAfterReviewAsync(entry.Id, check.KittingTime, account); } result.EntryCount = entries.Count; result.Message = $"订单 {order.BillNo} 评审完成(资源检查 {result.ResourceCheckCount} 条)"; } private async Task ConfirmOneOrderAsync( OrderWorkOrderGenerationService.OrderHeader order, SeOrderReviewExecuteResult result, List warnings, string account) { var entries = await LoadReviewableEntriesAsync(order.Id, order.TenantId, ["2"]); if (entries.Count == 0) throw Oops.Oh($"订单 {order.BillNo} 没有处于评审状态的明细行,请先完成订单评审"); foreach (var entry in entries) { var woCnt = await _db.Ado.GetIntAsync( """ SELECT COUNT(*) FROM WorkOrdMaster WHERE tenant_id = @TenantId AND BusinessID = @EntryId AND LOWER(TRIM(IFNULL(Status,''))) <> 'c' """, new SugarParameter("@TenantId", entry.TenantId), new SugarParameter("@EntryId", entry.Id)); // 无活跃工单:可能从未生成(库存可满足)或已关闭 → 仍允许交期确认 var confirmDate = entry.SysCapacityDate ?? entry.PlanDate; await UpdateEntryAfterConfirmAsync(entry.Id, confirmDate, account); result.EntryCount++; if (woCnt > 0) { 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} 无活跃工单(库存可满足或已关闭),已直接确认交期"); } result.WorkOrders = result.WorkOrders.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); result.Message = $"订单 {order.BillNo} 交期确认完成"; } private async Task LoadWorkOrdForEntryAsync(long tenantId, long entryId) { var rows = await _db.Ado.SqlQueryAsync( """ SELECT WorkOrd FROM WorkOrdMaster WHERE tenant_id = @TenantId AND BusinessID = @EntryId ORDER BY RecID DESC LIMIT 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@EntryId", entryId)); return rows.FirstOrDefault() ?? string.Empty; } /// /// 关闭指定订单行已有的未关闭工单(WorkOrdMaster.Status='C', IsActive=0;mes_morder.morder_state='关闭')。 /// 返回被关闭的工单数量。 /// private async Task CloseExistingWorkOrdersAsync(long tenantId, long entryId, string account) { // 查找该订单行下所有未关闭的工单号 var openWorkOrds = await _db.Ado.SqlQueryAsync( """ SELECT WorkOrd FROM WorkOrdMaster WHERE tenant_id = @TenantId AND BusinessID = @EntryId AND LOWER(TRIM(IFNULL(Status,''))) <> 'c' AND IFNULL(IsActive, 0) = 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@EntryId", entryId)); if (openWorkOrds.Count == 0) return 0; var workOrdList = string.Join(",", openWorkOrds.Select(w => $"'{w}'")); var now = DateTime.Now; // 关闭 WorkOrdMaster await _db.Ado.ExecuteCommandAsync( $""" UPDATE WorkOrdMaster SET Status = 'C', IsActive = 0, UpdateUser = @User, UpdateTime = @Now WHERE tenant_id = @TenantId AND BusinessID = @EntryId AND LOWER(TRIM(IFNULL(Status,''))) <> 'c' AND IFNULL(IsActive, 0) = 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@EntryId", entryId), new SugarParameter("@User", account), new SugarParameter("@Now", now)); // 同步关闭 mes_morder await _db.Ado.ExecuteCommandAsync( $""" UPDATE mes_morder SET morder_state = '关闭', update_by_name = @User, update_time = @Now WHERE tenant_id = @TenantId AND morder_no IN ({workOrdList}) AND IFNULL(morder_state, '') <> '关闭' """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@User", account), new SugarParameter("@Now", now)); return openWorkOrds.Count; } private static void ValidateEntryForResourceCheck(OrderWorkOrderGenerationService.OrderEntryLine entry) { if (string.IsNullOrWhiteSpace(entry.ItemNumber)) throw Oops.Oh($"订单行 {entry.EntrySeq} 物料编码不能为空"); if (entry.Qty is null or <= 0) throw Oops.Oh($"订单行 {entry.EntrySeq} 数量必须大于 0"); if (entry.PlanDate is null && entry.SysCapacityDate is null) throw Oops.Oh($"订单行 {entry.EntrySeq} 缺少计划交期"); } private async Task LoadOrderAsync(long orderId, long tenantId) { var rows = await _db.Ado.SqlQueryAsync( """ SELECT Id, bill_no AS BillNo, custom_no AS CustomNo, urgent AS Urgent, factory_id AS FactoryId, tenant_id AS TenantId FROM crm_seorder WHERE Id = @Id AND tenant_id = @TenantId AND IsDeleted = 0 LIMIT 1 """, new SugarParameter("@Id", orderId), new SugarParameter("@TenantId", tenantId)); return rows.FirstOrDefault(); } private async Task> LoadReviewableEntriesAsync( long orderId, long tenantId, IReadOnlyList progressList) { if (progressList.Count == 0) return new List(); var inClause = string.Join(", ", progressList.Select((_, i) => $"@P{i}")); var pars = new List { new("@OrderId", orderId), new("@TenantId", tenantId) }; for (var i = 0; i < progressList.Count; i++) pars.Add(new SugarParameter($"@P{i}", progressList[i])); return await _db.Ado.SqlQueryAsync( $""" SELECT Id, seorder_id AS SeOrderId, bill_no AS BillNo, entry_seq AS EntrySeq, item_number AS ItemNumber, item_name AS ItemName, specification AS Specification, unit AS Unit, bom_number AS BomNumber, qty AS Qty, plan_date AS PlanDate, sys_capacity_date AS SysCapacityDate, progress AS Progress, urgent AS Urgent, factory_id AS FactoryId, company_id AS CompanyId, tenant_id AS TenantId FROM crm_seorderentry WHERE seorder_id = @OrderId AND tenant_id = @TenantId AND IsDeleted = 0 AND COALESCE(NULLIF(progress, ''), '1') IN ({inClause}) ORDER BY entry_seq, Id """, pars); } private async Task UpdateEntryAfterReviewAsync(long entryId, DateTime? capacityDate, string account) { await _db.Ado.ExecuteCommandAsync( """ UPDATE crm_seorderentry SET progress = '2', sys_capacity_date = @CapacityDate, update_time = @Now WHERE Id = @Id AND IsDeleted = 0 """, new SugarParameter("@CapacityDate", capacityDate ?? (object)DBNull.Value), new SugarParameter("@Now", DateTime.Now), new SugarParameter("@Id", entryId)); } private async Task UpdateEntryAfterConfirmAsync(long entryId, DateTime? confirmDate, string account) { await _db.Ado.ExecuteCommandAsync( """ UPDATE crm_seorderentry SET progress = '3', date = COALESCE(date, @ConfirmDate), update_time = @Now WHERE Id = @Id AND IsDeleted = 0 """, new SugarParameter("@ConfirmDate", confirmDate ?? (object)DBNull.Value), new SugarParameter("@Now", DateTime.Now), new SugarParameter("@Id", entryId)); } private async Task UpdateEntriesProgressAsync(IReadOnlyList entryIds, string progress, string account) { if (entryIds.Count == 0) return; var idList = string.Join(",", entryIds); await _db.Ado.ExecuteCommandAsync( $""" UPDATE crm_seorderentry SET progress = @Progress, update_time = @Now WHERE Id IN ({idList}) AND IsDeleted = 0 """, new SugarParameter("@Progress", progress), new SugarParameter("@Now", DateTime.Now)); } private static string BuildAggregateMessage(string actionCode, SeOrderReviewExecuteResult r) { var woPart = r.WorkOrders.Count > 0 ? $",工单:{string.Join("、", r.WorkOrders.Distinct())}" : string.Empty; var closedPart = r.WorkOrderClosedCount > 0 ? $"、关闭 {r.WorkOrderClosedCount}" : string.Empty; return actionCode switch { ActionReview => $"评审完成 {r.OrderCount} 单、{r.EntryCount} 行,新建工单 {r.WorkOrderCreatedCount}、更新 {r.WorkOrderUpdatedCount}{closedPart}、资源检查 {r.ResourceCheckCount} 条{woPart}", ActionConfirm => $"交期确认完成 {r.OrderCount} 单、{r.EntryCount} 行{woPart}", _ => r.Message }; } private static string BuildSingleMessage(SeOrderReviewExecuteResult r) { if (r.WorkOrders.Count == 0) return r.Message; return $"{r.Message},工单:{string.Join("、", r.WorkOrders.Distinct())}"; } private async Task> TryTriggerMdpRefreshAsync() { var warnings = new List(); try { await _mdpSync.RunFullAsync(triggerType: "ORDER_REVIEW"); } catch (Exception ex) { warnings.Add($"S1 MDP/DWD 刷新未完成:{ex.Message}"); } return warnings; } // ══════════════════════════════════════════════════════════════ // 3级计划重排 — 辅助方法 // ══════════════════════════════════════════════════════════════ /// /// 校验订单明细行的物料编码是否与已生成工单的物料编码一致。 /// 若不一致则说明物料信息已变更,不允许重排。 /// private async Task ValidateMaterialNotChangedAsync(long orderId, long tenantId) { var mismatches = await _db.Ado.SqlQueryAsync( """ SELECT e.entry_seq AS EntrySeq, e.item_number AS EntryItemNum, w.ItemNum AS WoItemNum FROM crm_seorderentry e INNER JOIN WorkOrdMaster w ON w.BusinessID = e.Id AND w.tenant_id = e.tenant_id AND IFNULL(w.IsActive, 0) = 1 AND LOWER(TRIM(IFNULL(w.Status, ''))) <> 'c' WHERE e.seorder_id = @OrderId AND e.tenant_id = @TenantId AND e.IsDeleted = 0 AND TRIM(IFNULL(e.item_number, '')) <> '' AND TRIM(IFNULL(w.ItemNum, '')) <> '' AND TRIM(e.item_number) <> TRIM(w.ItemNum) """, new SugarParameter("@OrderId", orderId), new SugarParameter("@TenantId", tenantId)); if (mismatches.Count > 0) throw Oops.Oh("此订单行的物料信息有变更无法重排"); } /// /// 当工单状态为下达(R)/投产(W)/暂停(S)时,更新对应领料单明细数据。 /// 重新从 WorkOrdDetail 汇总物料需求,覆盖 NbrDetail 中的 QtyOrd。 /// private async Task UpdatePickingListForActiveWorkOrderAsync( long tenantId, string workOrd, string account, List warnings) { // 获取工单状态 var status = await _db.Ado.GetStringAsync( """ SELECT IFNULL(LOWER(TRIM(Status)), '') FROM WorkOrdMaster WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); // 仅处理 下达(R)/投产(W)/暂停(S) 状态的工单 if (status is not ("r" or "w" or "s")) return; // 查找领料单主记录 var nbrRows = await _db.Ado.SqlQueryAsync( """ SELECT RecID, Nbr, `Domain` FROM NbrMaster WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND Type = 'SM' AND IFNULL(TransType, '') = '' AND IFNULL(IsActive, 0) = 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); if (nbrRows.Count == 0) { warnings.Add($"工单 {workOrd} 状态为 {status.ToUpper()} 但无领料单,跳过领料单更新"); return; } var now = DateTime.Now; // 从 WorkOrdDetail 汇总最新物料需求 var details = await _db.Ado.SqlQueryAsync( """ SELECT d.ItemNum, SUM(d.QtyRequired) AS QtyRequired, MAX(IFNULL(d.UM, im.Um)) AS Unit, MAX(im.Descr) AS ItemName FROM WorkOrdDetail d LEFT JOIN ItemMaster im ON d.ItemNum = im.ItemNum AND im.tenant_id = d.tenant_id WHERE d.tenant_id = @TenantId AND d.WorkOrd = @WorkOrd AND IFNULL(d.IsActive, 0) = 1 GROUP BY d.ItemNum HAVING SUM(d.QtyRequired) > 0 ORDER BY d.ItemNum """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); foreach (var nbr in nbrRows) { var domain = string.IsNullOrWhiteSpace(nbr.Domain) ? tenantId.ToString() : nbr.Domain!.Trim(); if (domain.Length > 8) domain = domain[..8]; // 加载当前领料单明细行(仅未关闭的) var existingDetails = await _db.Ado.SqlQueryAsync( """ SELECT RecID, ItemNum, QtyOrd, QtyRec, CurrQtyOpened, Line FROM NbrDetail WHERE tenant_id = @TenantId AND Nbr = @Nbr AND Type = 'SM' AND IFNULL(IsActive, 0) = 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@Nbr", nbr.Nbr)); var detailMap = details.ToDictionary(d => d.ItemNum.Trim(), d => d); var existingMap = existingDetails.ToDictionary(d => (d.ItemNum ?? "").Trim(), d => d); // ── 1. 处理已有明细行:按 ItemNum 匹配 ── foreach (var existing in existingDetails) { var key = (existing.ItemNum ?? "").Trim(); if (detailMap.TryGetValue(key, out var newDetail)) { // 物料在工单明细中存在 var newQty = newDetail.QtyRequired; if (existing.QtyRec > 0) { // 已发料:判断新需求数是否大于已发料数 if (newQty > existing.QtyRec) { // 新需求 > 已发料 → 更新需求数 await _db.Ado.ExecuteCommandAsync( """ UPDATE NbrDetail SET QtyOrd = @QtyOrd, CurrQtyOpened = @CurrQtyOpened, UM = @UM, ItemName = @ItemName, UpdateUser = @User, UpdateTime = @Now WHERE RecID = @RecId """, new SugarParameter("@QtyOrd", newQty), new SugarParameter("@CurrQtyOpened", newQty), new SugarParameter("@UM", (object?)newDetail.Unit ?? DBNull.Value), new SugarParameter("@ItemName", (object?)newDetail.ItemName ?? DBNull.Value), new SugarParameter("@User", account), new SugarParameter("@Now", now), new SugarParameter("@RecId", existing.RecID)); } else { // 新需求 <= 已发料 → 关闭当前行 await _db.Ado.ExecuteCommandAsync( """ UPDATE NbrDetail SET Status = 'C', IsActive = 0, UpdateUser = @User, UpdateTime = @Now WHERE RecID = @RecId """, new SugarParameter("@User", account), new SugarParameter("@Now", now), new SugarParameter("@RecId", existing.RecID)); } } else { // 未发料 → 直接修改需求数 await _db.Ado.ExecuteCommandAsync( """ UPDATE NbrDetail SET QtyOrd = @QtyOrd, CurrQtyOpened = @CurrQtyOpened, UM = @UM, ItemName = @ItemName, UpdateUser = @User, UpdateTime = @Now WHERE RecID = @RecId """, new SugarParameter("@QtyOrd", newQty), new SugarParameter("@CurrQtyOpened", newQty), new SugarParameter("@UM", (object?)newDetail.Unit ?? DBNull.Value), new SugarParameter("@ItemName", (object?)newDetail.ItemName ?? DBNull.Value), new SugarParameter("@User", account), new SugarParameter("@Now", now), new SugarParameter("@RecId", existing.RecID)); } } else { // 物料明细中没有,但领料单明细有 → 关闭当前领料单明细行 await _db.Ado.ExecuteCommandAsync( """ UPDATE NbrDetail SET Status = 'C', IsActive = 0, UpdateUser = @User, UpdateTime = @Now WHERE RecID = @RecId """, new SugarParameter("@User", account), new SugarParameter("@Now", now), new SugarParameter("@RecId", existing.RecID)); } } // ── 2. 新增:物料明细中有,领料单明细没有的 ── var nextDetailId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) + 1 FROM NbrDetail"); short newLine = (short)(existingDetails.Count > 0 ? existingDetails.Max(d => d.Line) + 1 : 1); foreach (var d in details) { var key = d.ItemNum.Trim(); if (existingMap.ContainsKey(key)) continue; // 已处理 await _db.Ado.ExecuteCommandAsync( """ INSERT INTO NbrDetail ( RecID, `Domain`, Type, Nbr, Line, ItemNum, Dimension1, Dimension2, LocationFrom, LocationTo, QtyFrom, QtyTo, QtyOrd, QtyRec, CurrQtyOpened, UM, WorkOrd, ItemName, Status, IsActive, IsConfirm, IsChanged, BusinessID, NbrRecID, CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id ) VALUES ( @RecId, @Domain, 'SM', @Nbr, @Line, @ItemNum, '', '', '', '', 0, 0, @QtyOrd, 0, @CurrQtyOpened, @UM, @WorkOrd, @ItemName, '', 1, 0, 1, 0, @NbrRecId, @User, @Now, @User, @Now, @TenantId ) """, new SugarParameter("@RecId", nextDetailId++), new SugarParameter("@Domain", domain), new SugarParameter("@Nbr", nbr.Nbr), new SugarParameter("@Line", newLine++), new SugarParameter("@ItemNum", d.ItemNum), new SugarParameter("@QtyOrd", d.QtyRequired), new SugarParameter("@CurrQtyOpened", d.QtyRequired), new SugarParameter("@UM", (object?)d.Unit ?? DBNull.Value), new SugarParameter("@WorkOrd", workOrd), new SugarParameter("@ItemName", (object?)d.ItemName ?? DBNull.Value), new SugarParameter("@NbrRecId", nbr.RecID), new SugarParameter("@User", account.Length > 24 ? account[..24] : account), new SugarParameter("@Now", now), new SugarParameter("@TenantId", tenantId)); } // ── 3. 更新领料单主记录的更新时间和数量 ── await _db.Ado.ExecuteCommandAsync( """ UPDATE NbrMaster SET QtyOrd = (SELECT IFNULL(SUM(QtyOrd), 0) FROM NbrDetail WHERE Nbr = @Nbr AND Type = 'SM' AND IFNULL(IsActive, 1) = 1), UpdateUser = @User, UpdateTime = @Now WHERE RecID = @RecId """, new SugarParameter("@Nbr", nbr.Nbr), new SugarParameter("@User", account), new SugarParameter("@Now", now), new SugarParameter("@RecId", nbr.RecID)); } } /// /// 根据资源检查结果更新明细行系统建议交期(sys_capacity_date)。 /// private async Task UpdateEntrySysCapacityDateAsync(long entryId, DateTime? kittingTime, string account) { await _db.Ado.ExecuteCommandAsync( """ UPDATE crm_seorderentry SET sys_capacity_date = @CapacityDate, update_time = @Now WHERE Id = @Id AND IsDeleted = 0 """, new SugarParameter("@CapacityDate", kittingTime ?? (object)DBNull.Value), new SugarParameter("@Now", DateTime.Now), new SugarParameter("@Id", entryId)); } /// /// 根据订单及明细行生成交货单(出货计划 ShippingPlan / ShippingPlanDetail)。 /// 若该订单已存在出货计划则更新明细,否则新建。 /// private async Task GenerateShippingPlanFromOrderAsync( OrderWorkOrderGenerationService.OrderHeader order, List entries, string account, List warnings) { // 加载订单额外字段(客户名称、国家、日期) var orderInfo = await _db.Ado.SqlQueryAsync( """ SELECT custom_name AS CustomName, country AS Country, date AS OrderDate FROM crm_seorder WHERE Id = @Id AND tenant_id = @TenantId AND IsDeleted = 0 LIMIT 1 """, new SugarParameter("@Id", order.Id), new SugarParameter("@TenantId", order.TenantId)); var info = orderInfo.FirstOrDefault() ?? new OrderShippingInfoRow(); // 检查是否已存在该订单的出货计划明细 var existingPlanId = await _db.Ado.GetIntAsync( """ SELECT IFNULL(MAX(plan_id), 0) FROM ShippingPlanDetail WHERE tenant_id = @TenantId AND seorder_id = @OrderId AND IFNULL(IsActive, 1) = 1 """, new SugarParameter("@TenantId", order.TenantId), new SugarParameter("@OrderId", order.Id)); var now = DateTime.Now; var domain = order.TenantId.ToString(); if (domain.Length > 8) domain = domain[..8]; if (existingPlanId == 0) { // ── 新建出货计划主表 ── var planId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) + 1 FROM ShippingPlan"); await _db.Ado.ExecuteCommandAsync( """ INSERT INTO ShippingPlan ( RecID, `Domain`, LotSerial, ShippingDate, ShippingSite, Consignee, Priority, Status, Remark, IsActive, IsConfirm, CreateUser, CreateTime, tenant_id ) VALUES ( @PlanId, @Domain, @LotSerial, @ShippingDate, '', @Consignee, 0, '', '3级计划重排自动生成', 1, 1, @User, @Now, @TenantId ) """, new SugarParameter("@PlanId", planId), new SugarParameter("@Domain", domain), new SugarParameter("@LotSerial", order.BillNo ?? string.Empty), new SugarParameter("@ShippingDate", info.OrderDate ?? (object)DBNull.Value), new SugarParameter("@Consignee", (object?)info.CustomName ?? DBNull.Value), new SugarParameter("@User", account), new SugarParameter("@Now", now), new SugarParameter("@TenantId", order.TenantId)); // ── 新建出货计划明细 ── var nextDetailId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) + 1 FROM ShippingPlanDetail"); foreach (var entry in entries) { await _db.Ado.ExecuteCommandAsync( """ INSERT INTO ShippingPlanDetail ( RecID, `Domain`, plan_id, OrdNbr, bill_no, ItemNum, ItemName, Specification, Qty, OrdDate, Country, CustomNo, CustomName, seorder_id, sentry_id, Remark, Status, IsActive, IsConfirm, CreateUser, CreateTime, tenant_id ) VALUES ( @RecId, @Domain, @PlanId, @OrdNbr, @BillNo, @ItemNum, @ItemName, @Spec, @Qty, @OrdDate, @Country, @CustomNo, @CustomName, @SeOrderId, @SentryId, '', '', 1, 1, @User, @Now, @TenantId ) """, new SugarParameter("@RecId", nextDetailId++), new SugarParameter("@Domain", domain), new SugarParameter("@PlanId", planId), new SugarParameter("@OrdNbr", order.BillNo ?? string.Empty), new SugarParameter("@BillNo", entry.BillNo ?? order.BillNo ?? string.Empty), new SugarParameter("@ItemNum", (object?)entry.ItemNumber ?? DBNull.Value), new SugarParameter("@ItemName", (object?)entry.ItemName ?? DBNull.Value), new SugarParameter("@Spec", (object?)entry.Specification ?? DBNull.Value), new SugarParameter("@Qty", entry.Qty ?? 0), new SugarParameter("@OrdDate", entry.PlanDate ?? (object)DBNull.Value), new SugarParameter("@Country", (object?)info.Country ?? DBNull.Value), new SugarParameter("@CustomNo", (object?)order.CustomNo ?? DBNull.Value), new SugarParameter("@CustomName", (object?)info.CustomName ?? DBNull.Value), new SugarParameter("@SeOrderId", order.Id), new SugarParameter("@SentryId", entry.Id), new SugarParameter("@User", account), new SugarParameter("@Now", now), new SugarParameter("@TenantId", order.TenantId)); } warnings.Add($"已自动生成出货计划(ID={planId}),共 {entries.Count} 行明细"); } else { // ── 更新已有出货计划明细 ── await _db.Ado.ExecuteCommandAsync( """ UPDATE ShippingPlanDetail SET CustomName = @CustomName, Country = @Country, UpdateUser = @User, UpdateTime = @Now WHERE tenant_id = @TenantId AND seorder_id = @OrderId AND IFNULL(IsActive, 1) = 1 """, new SugarParameter("@CustomName", (object?)info.CustomName ?? DBNull.Value), new SugarParameter("@Country", (object?)info.Country ?? DBNull.Value), new SugarParameter("@User", account), new SugarParameter("@Now", now), new SugarParameter("@TenantId", order.TenantId), new SugarParameter("@OrderId", order.Id)); warnings.Add($"已更新出货计划(ID={existingPlanId})的明细数据"); } } // ══════════════════════════════════════════════════════════════ // 3级计划重排 — 内部 DTO // ══════════════════════════════════════════════════════════════ private sealed class NbrMasterRow { public int RecID { get; set; } public string Nbr { get; set; } = string.Empty; public string? Domain { get; set; } } private sealed class NbrDetailRow { public int RecID { get; set; } public string? ItemNum { get; set; } public decimal QtyOrd { get; set; } public decimal QtyRec { get; set; } public decimal CurrQtyOpened { get; set; } public short Line { get; set; } } private sealed class PickDetailRow { public string ItemNum { get; set; } = string.Empty; public decimal QtyRequired { get; set; } public string? Unit { get; set; } public string? ItemName { get; set; } } private sealed class OrderShippingInfoRow { public string? CustomName { get; set; } public string? Country { get; set; } public DateTime? OrderDate { get; set; } } }