using System.Text.Json; using Admin.NET.Plugin.AiDOP.DataPlatform.Executors; using Admin.NET.Plugin.AiDOP.DataPlatform.Sequence; using Admin.NET.Plugin.AiDOP.Entity.DataPlatform; using Admin.NET.Plugin.AiDOP.WorkOrder; using Microsoft.Extensions.Logging; using SqlSugar; namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms; /// /// WP4 · 下达建领料单。移植基准:旧 DOP ResourceExamineAppService.CreatePickBill / GenerateNbr。 /// /// 本库更新工单状态;165 经 Outbox 回写 Status/Batch(及里程碑工序 Status→r) /// NbrMaster/NbrDetail 在 165 直写(需 identity RecID 回填明细 NbrRecID,与旧 UoW 一致;单号走 WP2) /// 不做 MobileTask / QadTracking / 外协 PO / 库存占用(任务书本轮裁剪) /// D1:本动作不写 WorkOrdRouting C→W /// /// [ApiDescriptionSettings(Order = 327, Description = "下达建领料单")] [Route("api/aidop/wms-pick")] [AllowAnonymous] [NonUnify] public class CreatePickBillService : IDynamicApiController, ITransient { public const string TargetSource = "DOPDEMORQ_SQLSERVER"; private const string NbrTypeBill = "SM"; private const string NbrTypeAllocate = "sm"; private readonly ISqlSugarClient _db; private readonly MdpSourceScopeFactory _scopeFactory; private readonly INbrSequenceService _seq; private readonly KeeperResolveService _keeper; private readonly MdpOutboxEnqueueService _enqueue; private readonly ILogger _logger; public CreatePickBillService( ISqlSugarClient db, MdpSourceScopeFactory scopeFactory, INbrSequenceService seq, KeeperResolveService keeper, MdpOutboxEnqueueService enqueue, ILogger logger) { _db = db; _scopeFactory = scopeFactory; _seq = seq; _keeper = keeper; _enqueue = enqueue; _logger = logger; } public sealed class CreatePickBillInput { /// 工单号,逗号分隔;空则按 OrdDate 窗口取 Status=p public string? WorkOrd { get; set; } public string Domain { get; set; } = "8010"; public string UserAccount { get; set; } = "aidop"; public long TenantId { get; set; } } [DisplayName("下达生成领料单")] [HttpPost("create")] public async Task Create([FromBody] CreatePickBillInput input, CancellationToken ct = default) { if (input == null) throw Oops.Oh("body 不能为空"); var domain = string.IsNullOrWhiteSpace(input.Domain) ? "8010" : input.Domain.Trim(); var user = string.IsNullOrWhiteSpace(input.UserAccount) ? "aidop" : input.UserAccount.Trim(); var masters = await LoadCandidatesAsync(domain, input.WorkOrd, ct); if (masters.Count == 0) return new { ok = false, message = "没有需要下达的工单。" }; var ss = await _scopeFactory.GetScopeAsync(TargetSource, ct); var already = await ss.Ado.SqlQueryAsync( "SELECT DISTINCT WorkOrd FROM NbrMaster WHERE Domain=@d AND Type=@t", new SugarParameter("@d", domain), new SugarParameter("@t", NbrTypeBill)); var alreadySet = new HashSet(already.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase); masters = masters.Where(m => !alreadySet.Contains(m.WorkOrd ?? "")).ToList(); if (masters.Count == 0) return new { ok = false, message = "当前选择的工单包含已生成领料单。" }; var workOrds = masters.Select(m => m.WorkOrd!).Distinct().ToList(); var details = await _db.Queryable() .Where(d => d.IsActive && workOrds.Contains(d.WorkOrd!)) .ToListAsync(ct); if (details.Count == 0) return new { ok = false, message = "工单无物料明细,无法建领料单。" }; var itemNums = details.Select(d => d.ItemNum!).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); var itemMap = new Dictionary(StringComparer.OrdinalIgnoreCase); if (itemNums.Count > 0) { // Universal.ItemMaster 实体列与本库不完全一致,只取建单所需字段 var inSql = string.Join(",", itemNums.Select((_, k) => "@i" + k)); var pars = itemNums.Select((n, k) => new SugarParameter("@i" + k, n)).ToArray(); var items = await _db.Ado.SqlQueryAsync( $"SELECT ItemNum, Descr, UM, Location FROM ItemMaster WHERE ItemNum IN ({inSql})", pars); foreach (var it in items) { if (!string.IsNullOrWhiteSpace(it.ItemNum) && !itemMap.ContainsKey(it.ItemNum)) itemMap[it.ItemNum] = it; } } var keeperHits = await _keeper.ResolveByItemsAsync(domain, itemNums, ct); var keeperByItem = keeperHits.GroupBy(h => h.ItemNum, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var draftMasters = new List(); var draftDetails = new List(); foreach (var wo in masters) { var wods = details.Where(d => string.Equals(d.WorkOrd, wo.WorkOrd, StringComparison.OrdinalIgnoreCase)).ToList(); if (wods.Count == 0) continue; BuildOne(wo, wods, itemMap, keeperByItem, user, draftMasters, draftDetails); } if (draftMasters.Count == 0) return new { ok = false, message = "未生成任何领料单(明细为空或物料无效)。" }; var nbrs = await _seq.AllocateAsync(NbrTypeAllocate, draftMasters.Count, domain, user, ct); if (nbrs.Count < draftMasters.Count) throw Oops.Oh($"取号不足:需要 {draftMasters.Count},得到 {nbrs.Count}"); for (var i = 0; i < draftMasters.Count; i++) { var nbr = nbrs[i]; draftMasters[i].Nbr = nbr; var woTail = Tail(draftMasters[i].WorkOrd); draftMasters[i].Address = woTail; draftMasters[i].Ufld1 = woTail; foreach (var d in draftDetails.Where(x => x.TempKey == draftMasters[i].TempKey)) { d.Nbr = nbr; d.Address = woTail; d.OrdNbr = woTail; d.ERPfld1 = woTail; d.OrdLine = d.Line; d.ERPfld2 = d.Line.ToString(); } } // 165 直写领料主从(拿 RecID) await InsertNbrOn165Async(ss, draftMasters, draftDetails, ct); // 本库工单状态 var touchedOrds = draftMasters.Select(x => x.WorkOrd).Distinct().ToList(); var now = DateTime.Now; foreach (var wo in masters.Where(m => touchedOrds.Contains(m.WorkOrd!))) { var batch = Tail(wo.WorkOrd!); await _db.Updateable() .SetColumns(m => new WorkOrdMaster { Status = "r", Batch = batch, UpdateUser = user, UpdateTime = now }) .Where(m => m.WorkOrd == wo.WorkOrd) .ExecuteCommandAsync(ct); } await _db.Updateable() .SetColumns(r => new WorkOrdRouting { Status = "r", UpdateUser = user, UpdateTime = now }) .Where(r => touchedOrds.Contains(r.WorkOrd!) && r.IsActive && r.Status != null && r.Status.ToLower() != "c" && r.Status.ToUpper() != "W") .ExecuteCommandAsync(ct); // Outbox:165 工单头 UPSERT(无则插入计划侧最小集,有则只改 Status/Batch) var trace = $"pick|{domain}|{DateTime.Now:yyyyMMddHHmmss}|{Guid.NewGuid():N}"[..48]; var enqueued = 0; var masterByOrd = masters.Where(m => touchedOrds.Contains(m.WorkOrd!)) .GroupBy(m => m.WorkOrd!, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); foreach (var wo in touchedOrds) { var batch = Tail(wo); masterByOrd.TryGetValue(wo, out var local); var insert = BuildWorkOrdInsert(domain, wo, batch, user, now, local); var update = new Dictionary { ["Status"] = "r", ["Batch"] = batch, ["UpdateUser"] = Trunc(user, 24), ["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss") }; if (await EnqueueAsync(input.TenantId, $"{trace}|wom|{wo}", "PICK_WOM_UPSERT", new { op = "UPSERT", table = "WorkOrdMaster", keys = new Dictionary { ["Domain"] = domain, ["WorkOrd"] = wo }, update, expect = new Dictionary(), insert }, ct)) enqueued++; } // WorkOrdRouting 多行 UPDATE:Outbox 执行器按全 keys 等值,不适合「WorkOrd 下多工序」。 // 与 Nbr 相同,里程碑工序在 165 直写(仅 MilestoneOp=1 且 Status<>C)。 await ss.Ado.ExecuteCommandAsync( @"UPDATE WorkOrdRouting SET Status='r', UpdateUser=@u, UpdateTime=@t WHERE Domain=@d AND WorkOrd IN (" + string.Join(",", touchedOrds.Select((_, i) => "@w" + i)) + @") AND ISNULL(MilestoneOp,0)=1 AND ISNULL(Status,'') <> 'C' AND ISNULL(Status,'') <> 'c'", BuildInParams(domain, user, now, touchedOrds)); _logger.LogInformation( "[CreatePickBill] domain={Domain} workOrds={Count} nbrs={Nbrs} outbox={Outbox} trace={Trace}", domain, touchedOrds.Count, draftMasters.Count, enqueued, trace); return new { ok = true, message = "ok", trace, workOrds = touchedOrds, nbrs = draftMasters.Select(m => new { m.WorkOrd, m.Nbr, m.User1, detailCount = draftDetails.Count(d => d.Nbr == m.Nbr) }), outboxEnqueued = enqueued }; } private async Task> LoadCandidatesAsync(string domain, string? workOrdCsv, CancellationToken ct) { if (string.IsNullOrWhiteSpace(workOrdCsv)) { var from = DateTime.Now.Date.AddDays(1); var to = DateTime.Now.Date.AddDays(4); return await _db.Queryable() .Where(m => m.Domain == domain && m.OrdDate >= from && m.OrdDate < to && m.Status != null && m.Status.ToLower() == "p") .ToListAsync(ct); } var list = workOrdCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (list.Length == 0) return new List(); // 显式工单号:不强制 Domain/本库仍为 p(本库 Domain 可能是租户号;下达可能已置 r) return await _db.Queryable() .Where(m => list.Contains(m.WorkOrd!) && m.Status != null && m.Status.ToLower() != "c" && m.Status.ToLower() != "w") .ToListAsync(ct); } private static Dictionary BuildWorkOrdInsert( string domain, string workOrd, string batch, string user, DateTime now, WorkOrdMaster? local) { var insert = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Domain"] = domain, ["WorkOrd"] = workOrd, ["ID"] = workOrd, ["Status"] = "r", ["Batch"] = batch, ["ItemNum"] = local?.ItemNum ?? "", ["Site"] = local?.Site ?? "", ["Typed"] = local?.Typed ?? "", ["QtyOrded"] = local?.QtyOrded ?? 0m, ["ProdLine"] = local?.ProdLine ?? "", ["Location"] = local?.Location ?? "", ["Priority"] = local?.Priority ?? 0m, ["Urgent"] = local?.Urgent ?? 0, ["ItemName"] = local?.ItemName ?? "", ["Department"] = local?.Department ?? "", ["RoutingCode"] = local?.RoutingCode ?? "", ["BOMFormula"] = local?.BOMFormula ?? "", ["Project"] = local?.Project ?? "", ["Drawing"] = local?.Drawing ?? "", ["Rev"] = local?.Rev ?? "", ["CustNo"] = local?.CustNo ?? "", ["BusinessID"] = local?.BusinessID ?? 0L, ["IsActive"] = true, ["IsConfirm"] = local?.IsConfirm ?? false, ["CreateUser"] = Trunc(local?.CreateUser ?? user, 24), ["UpdateUser"] = Trunc(user, 24), ["CreateTime"] = (local?.CreateTime ?? now).ToString("yyyy-MM-dd HH:mm:ss"), ["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss") }; if (local?.OrdDate != null) insert["OrdDate"] = local.OrdDate.Value.ToString("yyyy-MM-dd HH:mm:ss"); if (local?.DueDate != null) insert["DueDate"] = local.DueDate.Value.ToString("yyyy-MM-dd HH:mm:ss"); if (local?.ReleaseDate != null) insert["ReleaseDate"] = local.ReleaseDate.Value.ToString("yyyy-MM-dd HH:mm:ss"); return insert; } private static void BuildOne( WorkOrdMaster wo, List wods, Dictionary itemMap, Dictionary keeperByItem, string userAccount, List masters, List details) { var today = DateTime.Now.Date; var ord = (wo.OrdDate ?? today).Date; var eff = ord.AddDays(-1); if (eff < today) eff = today; var start = eff; var temp = Guid.NewGuid().ToString("N"); var keepers = new List(); short line = 1; var agg = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var a in wods) { if (string.IsNullOrWhiteSpace(a.ItemNum)) continue; itemMap.TryGetValue(a.ItemNum, out var im); var locFrom = !string.IsNullOrWhiteSpace(a.Location) ? a.Location! : (im?.Location ?? ""); if (keeperByItem.TryGetValue(a.ItemNum, out var kh) && !string.IsNullOrWhiteSpace(kh.Employee)) keepers.Add(kh.Employee); if (agg.TryGetValue(a.ItemNum, out var exist)) { exist.QtyOrd += a.QtyRequired; exist.CurrQtyOpened += a.QtyRequired; continue; } var d = new DraftNbrDetail { TempKey = temp, Domain = wo.Domain ?? "8010", Type = NbrTypeBill, ItemNum = a.ItemNum, QtyFrom = 0, QtyTo = 0, LocationFrom = locFrom, LocationTo = wo.Location ?? "", WorkOrd = wo.WorkOrd ?? "", QtyOrd = a.QtyRequired, CurrQtyOpened = a.QtyRequired, Line = line++, UM = im?.UM ?? "", LotSerial = a.LotSerial ?? "", ItemName = im?.Descr ?? "", Status = "", CreateUser = userAccount, UpdateUser = userAccount, CreateTime = DateTime.Now, UpdateTime = DateTime.Now }; agg[a.ItemNum] = d; details.Add(d); } if (agg.Count == 0) return; var (user1, user2) = KeeperResolveService.FormatKeeperUsers( keepers.Distinct().Select(e => new KeeperResolveService.KeeperHit { Employee = e, ItemNum = "" })); masters.Add(new DraftNbrMaster { TempKey = temp, Domain = wo.Domain ?? "8010", Type = NbrTypeBill, Remark = "下达自动领料", ProdLine = wo.ProdLine ?? "", EffDate = eff, Date = start, Status = "", WorkOrd = wo.WorkOrd ?? "", QtyOrd = wo.QtyOrded, IsActive = true, IsChanged = true, Name = userAccount.Length > 12 ? userAccount[..12] : userAccount, Department = "101", CreateUser = userAccount, UpdateUser = userAccount, CreateTime = DateTime.Now, UpdateTime = DateTime.Now, TransType = "", User1 = user1, User2 = user2 }); } private static async Task InsertNbrOn165Async( ISqlSugarClient ss, List masters, List details, CancellationToken ct) { foreach (var m in masters) { var recIdObj = await ss.Ado.SqlQuerySingleAsync( @"INSERT INTO NbrMaster (Domain, Type, Nbr, [Print], Status, Remark, Date, User1, User2, ProdLine, EffDate, WorkOrd, QtyOrd, QtyRec, Address, BusinessID, CreateUser, UpdateUser, CreateTime, UpdateTime, IsActive, IsConfirm, IsChanged, Department, Name, TransType, Ufld1, IsGP12Checked, IsPlan, IsReturn, Op) OUTPUT INSERTED.RecID VALUES (@Domain, @Type, @Nbr, 0, @Status, @Remark, @Date, @User1, @User2, @ProdLine, @EffDate, @WorkOrd, @QtyOrd, 0, @Address, 0, @CreateUser, @UpdateUser, @CreateTime, @UpdateTime, 1, 0, 1, @Department, @Name, @TransType, @Ufld1, 0, 0, 0, 0)", new SugarParameter("@Domain", m.Domain), new SugarParameter("@Type", m.Type), new SugarParameter("@Nbr", m.Nbr), new SugarParameter("@Status", m.Status ?? ""), new SugarParameter("@Remark", m.Remark ?? ""), new SugarParameter("@Date", m.Date), new SugarParameter("@User1", m.User1 ?? ""), new SugarParameter("@User2", m.User2 ?? "[]"), new SugarParameter("@ProdLine", m.ProdLine ?? ""), new SugarParameter("@EffDate", m.EffDate), new SugarParameter("@WorkOrd", m.WorkOrd), new SugarParameter("@QtyOrd", m.QtyOrd), new SugarParameter("@Address", m.Address ?? ""), new SugarParameter("@CreateUser", Trunc(m.CreateUser, 24)), new SugarParameter("@UpdateUser", Trunc(m.UpdateUser, 24)), new SugarParameter("@CreateTime", m.CreateTime), new SugarParameter("@UpdateTime", m.UpdateTime), new SugarParameter("@Department", m.Department ?? ""), new SugarParameter("@Name", m.Name ?? ""), new SugarParameter("@TransType", m.TransType ?? ""), new SugarParameter("@Ufld1", m.Ufld1 ?? "")); if (recIdObj is null or 0) throw new InvalidOperationException($"NbrMaster 插入未返回 RecID:{m.Nbr}"); var recId = recIdObj.Value; foreach (var d in details.Where(x => x.TempKey == m.TempKey)) { await ss.Ado.ExecuteCommandAsync( @"INSERT INTO NbrDetail (Domain, Type, Nbr, Line, ItemNum, Dimension1, Dimension2, LocationFrom, LocationTo, QtyFrom, QtyTo, UM, [Print], Status, LotSerial, WorkOrd, QtyOrd, QtyRec, Address, BusinessID, CreateUser, UpdateUser, CreateTime, UpdateTime, IsActive, IsConfirm, QtyCache, CurrQtyOpened, IsChanged, NbrRecID, OrdNbr, ItemName, ERPfld1, ERPfld2, OrdLine, IsGP12Demand, IsGP12Checked, Material, SeqID) VALUES (@Domain, @Type, @Nbr, @Line, @ItemNum, '', '', @LocationFrom, @LocationTo, 0, 0, @UM, 0, @Status, @LotSerial, @WorkOrd, @QtyOrd, 0, @Address, 0, @CreateUser, @UpdateUser, @CreateTime, @UpdateTime, 1, 0, 0, @CurrQtyOpened, 1, @NbrRecID, @OrdNbr, @ItemName, @ERPfld1, @ERPfld2, @OrdLine, 0, 0, 0, 0)", new SugarParameter("@Domain", d.Domain), new SugarParameter("@Type", d.Type), new SugarParameter("@Nbr", d.Nbr), new SugarParameter("@Line", d.Line), new SugarParameter("@ItemNum", d.ItemNum), new SugarParameter("@LocationFrom", Trunc(d.LocationFrom, 8)), new SugarParameter("@LocationTo", Trunc(d.LocationTo, 8)), new SugarParameter("@UM", Trunc(d.UM, 8)), new SugarParameter("@Status", d.Status ?? ""), new SugarParameter("@LotSerial", d.LotSerial ?? ""), new SugarParameter("@WorkOrd", d.WorkOrd), new SugarParameter("@QtyOrd", d.QtyOrd), new SugarParameter("@Address", Trunc(d.Address, 60)), new SugarParameter("@CreateUser", Trunc(d.CreateUser, 24)), new SugarParameter("@UpdateUser", Trunc(d.UpdateUser, 24)), new SugarParameter("@CreateTime", d.CreateTime), new SugarParameter("@UpdateTime", d.UpdateTime), new SugarParameter("@CurrQtyOpened", d.CurrQtyOpened), new SugarParameter("@NbrRecID", recId), new SugarParameter("@OrdNbr", Trunc(d.OrdNbr, 48)), new SugarParameter("@ItemName", Trunc(d.ItemName, 1000)), new SugarParameter("@ERPfld1", Trunc(d.ERPfld1, 200)), new SugarParameter("@ERPfld2", Trunc(d.ERPfld2, 200)), new SugarParameter("@OrdLine", d.OrdLine)); } } } private async Task EnqueueAsync(long tenantId, string idem, string action, object payload, CancellationToken ct) { var json = JsonSerializer.Serialize(payload); var item = new MdpOutbox { TenantId = tenantId, TargetSourceCode = TargetSource, ActionCode = action, IdemKey = idem.Length > 200 ? idem[..200] : idem, PayloadJson = json }; return await _enqueue.TryEnqueueAsync(item, ct); } private static SugarParameter[] BuildInParams(string domain, string user, DateTime now, List workOrds) { var pars = new List { new("@d", domain), new("@u", Trunc(user, 24)), new("@t", now) }; for (var i = 0; i < workOrds.Count; i++) pars.Add(new SugarParameter("@w" + i, workOrds[i])); return pars.ToArray(); } private static string Tail(string workOrd) => workOrd.Length > 1 ? workOrd[1..] : workOrd; private sealed class ItemLite { public string ItemNum { get; set; } = ""; public string? Descr { get; set; } public string? UM { get; set; } public string? Location { get; set; } } private static string Trunc(string? s, int max) => string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s[..max]); private sealed class DraftNbrMaster { public string TempKey { get; set; } = ""; public string Domain { get; set; } = ""; public string Type { get; set; } = ""; public string Nbr { get; set; } = ""; public string? Remark { get; set; } public string? ProdLine { get; set; } public DateTime EffDate { get; set; } public DateTime Date { get; set; } public string? Status { get; set; } public string WorkOrd { get; set; } = ""; public decimal QtyOrd { get; set; } public bool IsActive { get; set; } public bool IsChanged { get; set; } public string? Name { get; set; } public string? Department { get; set; } public string? CreateUser { get; set; } public string? UpdateUser { get; set; } public DateTime CreateTime { get; set; } public DateTime UpdateTime { get; set; } public string? TransType { get; set; } public string? User1 { get; set; } public string? User2 { get; set; } public string? Address { get; set; } public string? Ufld1 { get; set; } } private sealed class DraftNbrDetail { public string TempKey { get; set; } = ""; public string Domain { get; set; } = ""; public string Type { get; set; } = ""; public string Nbr { get; set; } = ""; public string ItemNum { get; set; } = ""; public decimal QtyFrom { get; set; } public decimal QtyTo { get; set; } public string LocationFrom { get; set; } = ""; public string LocationTo { get; set; } = ""; public string WorkOrd { get; set; } = ""; public decimal QtyOrd { get; set; } public decimal CurrQtyOpened { get; set; } public short Line { get; set; } public string UM { get; set; } = ""; public string LotSerial { get; set; } = ""; public string ItemName { get; set; } = ""; public string Status { get; set; } = ""; public string? CreateUser { get; set; } public string? UpdateUser { get; set; } public DateTime CreateTime { get; set; } public DateTime UpdateTime { get; set; } public string? Address { get; set; } public string? OrdNbr { get; set; } public string? ERPfld1 { get; set; } public string? ERPfld2 { get; set; } public short OrdLine { get; set; } } }