using System.Text.Json; using Admin.NET.Plugin.AiDOP.DataPlatform.Executors; using Admin.NET.Plugin.AiDOP.Entity.DataPlatform; using Admin.NET.Plugin.AiDOP.WorkOrder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using SqlSugar; namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms; /// /// WP4 · 计划侧工单入 165:本库 WorkOrdMaster/Detail/Routing 快照 → Outbox UPSERT。 /// 挂接点:订单评审 / 计划刷新 / 交期确认;下达侧只推进状态(CreatePickBill)。 /// [ApiDescriptionSettings(Order = 328, Description = "工单推送 MES")] [Route("api/aidop/wms-workord")] [AllowAnonymous] [NonUnify] public class WorkOrdMesPushService : IDynamicApiController, ITransient { public const string TargetSource = "DOPDEMORQ_SQLSERVER"; public const string ActionWom = "WO_MES_WOM"; public const string ActionWod = "WO_MES_WOD"; public const string ActionWor = "WO_MES_WOR"; private readonly ISqlSugarClient _db; private readonly MdpSourceScopeFactory _scopeFactory; private readonly MdpOutboxEnqueueService _enqueue; private readonly MdpOutboxWakeSignal _wake; private readonly UserManager _userManager; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; public WorkOrdMesPushService( ISqlSugarClient db, MdpSourceScopeFactory scopeFactory, MdpOutboxEnqueueService enqueue, MdpOutboxWakeSignal wake, UserManager userManager, IHttpContextAccessor httpContextAccessor, ILogger logger) { _db = db; _scopeFactory = scopeFactory; _enqueue = enqueue; _wake = wake; _userManager = userManager; _httpContextAccessor = httpContextAccessor; _logger = logger; } public sealed class PushInput { public string WorkOrd { get; set; } = ""; public long TenantId { get; set; } public string? Domain { get; set; } } public sealed class PushResult { public bool Ok { get; set; } public string WorkOrd { get; set; } = ""; public string Domain { get; set; } = ""; public int Enqueued { get; set; } public int DetailCount { get; set; } public int RoutingCount { get; set; } public string? Message { get; set; } } /// 补偿 / 联调:按本库快照入队推送 165。 [DisplayName("推送工单到 MES")] [HttpPost("push")] public async Task Push([FromBody] PushInput input, CancellationToken ct = default) { if (input == null || string.IsNullOrWhiteSpace(input.WorkOrd)) throw Oops.Oh("工单号不能为空"); if (input.TenantId <= 0) throw Oops.Oh("租户号无效,请指定有效的 TenantId"); var jwtTenantId = _userManager.TenantId; if (jwtTenantId > 0 && input.TenantId != jwtTenantId) throw Oops.Oh("请求租户与当前登录租户不一致"); if (jwtTenantId <= 0) { var clientIp = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown"; _logger.LogInformation( "[WorkOrdMesPush] 无登录态推送 audit ip={ClientIp} workOrd={WorkOrd} tenantId={TenantId}", clientIp, input.WorkOrd.Trim(), input.TenantId); } var tenantId = input.TenantId; var workOrd = input.WorkOrd.Trim(); var domain = string.IsNullOrWhiteSpace(input.Domain) ? await ResolveDomainAsync(tenantId, workOrd) : input.Domain.Trim(); return await EnqueueSnapshotAsync(tenantId, domain, workOrd, ct); } /// 评审/刷新/确认后调用:失败只记日志,不抛(避免拖垮本库评审事务)。 public async Task TryEnqueueSnapshotSafeAsync(long tenantId, string workOrd, List? warnings = null) { try { if (string.IsNullOrWhiteSpace(workOrd)) return; var domain = await ResolveDomainAsync(tenantId, workOrd); var r = await EnqueueSnapshotAsync(tenantId, domain, workOrd); warnings?.Add($"工单 {workOrd} 已入队推送 MES({r.Enqueued} 条 Outbox,明细 {r.DetailCount}/工序 {r.RoutingCount})"); } catch (Exception ex) { _logger.LogWarning(ex, "[WorkOrdMesPush] enqueue failed tenant={Tenant} wo={WorkOrd}", tenantId, workOrd); warnings?.Add($"工单 {workOrd} 推送 MES 入队失败:{ex.Message}(可稍后调用 /api/aidop/wms-workord/push 补偿)"); } } /// 从本库读三人组并入队(主表 → 明细 → 工序);末尾 Pulse 一次。 public async Task EnqueueSnapshotAsync( long tenantId, string domain, string workOrd, CancellationToken ct = default) { domain = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim(); workOrd = workOrd.Trim(); var masters = await _db.Queryable() .Where(m => m.TenantId == tenantId && m.WorkOrd == workOrd) .Take(1) .ToListAsync(ct); if (masters.Count == 0) throw Oops.Oh($"本库未找到工单 {workOrd}"); var master = masters[0]; var details = await _db.Ado.SqlQueryAsync( """ SELECT TRIM(IFNULL(Domain, '')) AS Domain, WorkOrd, IFNULL(LineNum, 0) AS LineNum, TRIM(IFNULL(ItemNum, '')) AS ItemNum, IFNULL(Op, 0) AS Op, TRIM(IFNULL(Location, '')) AS Location, TRIM(IFNULL(Site, '')) AS Site, IFNULL(QtyRequired, 0) AS QtyRequired, IFNULL(FrozenBOMQty, 0) AS FrozenBOMQty, TRIM(IFNULL(LotSerial, '')) AS LotSerial, TRIM(IFNULL(Status, '')) AS Status, TRIM(IFNULL(Typed, '')) AS Typed, TRIM(IFNULL(UM, '')) AS UM, IFNULL(IsActive, 0) AS IsActive, IFNULL(BusinessID, 0) AS BusinessID, CreateUser, CreateTime, UpdateUser, UpdateTime FROM WorkOrdDetail WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1 ORDER BY LineNum, Op, ItemNum """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); var routings = await _db.Ado.SqlQueryAsync( """ SELECT TRIM(IFNULL(Domain, '')) AS Domain, WorkOrd, OP, TRIM(IFNULL(ItemNum, '')) AS ItemNum, TRIM(IFNULL(Descr, '')) AS Descr, IFNULL(MilestoneOp, 0) AS MilestoneOp, IFNULL(ParentOp, 0) AS ParentOp, IFNULL(QtyOrded, 0) AS QtyOrded, TRIM(IFNULL(Status, '')) AS Status, TRIM(IFNULL(ProdLine, '')) AS ProdLine, TRIM(IFNULL(WorkCtr, '')) AS WorkCtr, TRIM(IFNULL(Machine, '')) AS Machine, IFNULL(RunCrew, 0) AS RunCrew, IFNULL(StdSetupTime, 0) AS StdSetupTime, IFNULL(ProcessOut, 0) AS ProcessOut, IFNULL(IsActive, 0) AS IsActive, CreateUser, CreateTime, UpdateUser, UpdateTime FROM WorkOrdRouting WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1 ORDER BY OP """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); if (details.Count == 0) throw Oops.Oh($"工单 {workOrd} 无活跃物料明细,无法推送 MES"); if (routings.Count == 0) throw Oops.Oh($"工单 {workOrd} 无活跃工序,无法推送 MES"); var masterStatus = StatusForMasterPush(master.Status); var now = DateTime.Now; var enqueued = 0; var womInsert = BuildMasterInsert(domain, master, masterStatus, now); var womUpdate = BuildMasterUpdate(master, masterStatus, now); // 165 唯一索引 IX_WorkOrdMaster = (Domain, Typed, WorkOrd) var typed = string.IsNullOrWhiteSpace(master.Typed) ? "" : master.Typed.Trim(); if (await EnqueueRowAsync(tenantId, $"wo|{domain}|{workOrd}|wom", ActionWom, "WorkOrdMaster", new Dictionary { ["Domain"] = domain, ["Typed"] = typed, ["WorkOrd"] = workOrd }, womInsert, womUpdate, ct)) enqueued++; foreach (var d in details) { var line = d.LineNum; var item = d.ItemNum ?? ""; var op = d.Op; // 165 唯一索引 IX_WorkOrdDetail = (Domain, ID, WorkOrd, Op, Line, ItemNum, Dimension1, Dimension2) var keys = new Dictionary { ["Domain"] = domain, ["ID"] = "", ["WorkOrd"] = workOrd, ["Op"] = op, ["Line"] = line, ["ItemNum"] = item, ["Dimension1"] = "", ["Dimension2"] = "" }; var insert = BuildDetailInsert(domain, workOrd, d, now); var update = BuildDetailUpdate(d, now); var idem = $"wo|{domain}|{workOrd}|wod|{line}|{op}|{item}"; if (await EnqueueRowAsync(tenantId, idem, ActionWod, "WorkOrdDetail", keys, insert, update, ct)) enqueued++; } foreach (var r in routings) { var routingStatus = StatusForRoutingPush(masterStatus, r.Status); // 165 唯一索引 IX_WorkOrdRouting_1 = (Domain, ID, OP, WorkOrd) var keys = new Dictionary { ["Domain"] = domain, ["ID"] = "", ["OP"] = r.OP, ["WorkOrd"] = workOrd }; var insert = BuildRoutingInsert(domain, workOrd, r, routingStatus, now); var update = BuildRoutingUpdate(r, routingStatus, now); var idem = $"wo|{domain}|{workOrd}|wor|{r.OP}"; if (await EnqueueRowAsync(tenantId, idem, ActionWor, "WorkOrdRouting", keys, insert, update, ct)) enqueued++; } _wake.Pulse(); return new PushResult { Ok = true, WorkOrd = workOrd, Domain = domain, Enqueued = enqueued, DetailCount = details.Count, RoutingCount = routings.Count, Message = $"已入队 {enqueued} 条(明细 {details.Count} / 工序 {routings.Count})" }; } /// 探测 165 是否已有完整工单三人组。 public async Task<(bool Ready, int MasterCnt, int DetailCnt, int RoutingCnt)> ProbeReadyOnMesAsync( string domain, string workOrd, CancellationToken ct = default) { domain = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim(); workOrd = workOrd.Trim(); var ss = await _scopeFactory.GetScopeAsync(TargetSource, ct); var masterCnt = await ss.Ado.GetIntAsync( "SELECT COUNT(1) FROM WorkOrdMaster WHERE Domain=@d AND WorkOrd=@w", new SugarParameter("@d", domain), new SugarParameter("@w", workOrd)); var detailCnt = await ss.Ado.GetIntAsync( "SELECT COUNT(1) FROM WorkOrdDetail WHERE Domain=@d AND WorkOrd=@w AND ISNULL(IsActive,0)=1", new SugarParameter("@d", domain), new SugarParameter("@w", workOrd)); var routingCnt = await ss.Ado.GetIntAsync( "SELECT COUNT(1) FROM WorkOrdRouting WHERE Domain=@d AND WorkOrd=@w AND ISNULL(IsActive,0)=1", new SugarParameter("@d", domain), new SugarParameter("@w", workOrd)); return (masterCnt > 0 && detailCnt > 0 && routingCnt > 0, masterCnt, detailCnt, routingCnt); } /// Mes 下达前置:165 上须已有完整工单三人组。 public async Task AssertReadyOnMesAsync(string domain, string workOrd, CancellationToken ct = default) { try { var probe = await ProbeReadyOnMesAsync(domain, workOrd, ct); if (!probe.Ready) { throw Oops.Oh(PickBillUserMessages.ForReleaseFailure("WO_MES_NOT_READY")); } } catch (Exception ex) when (ex is not AppFriendlyException) { throw Oops.Oh(PickBillUserMessages.ForReleaseFailure("MES_UNREACHABLE")); } } private async Task ResolveDomainAsync(long tenantId, string workOrd) { var domain = await _db.Ado.GetStringAsync( """ SELECT TRIM(IFNULL(`Domain`, '')) FROM WorkOrdMaster WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); return string.IsNullOrWhiteSpace(domain) ? "8010" : domain; } private async Task EnqueueRowAsync( long tenantId, string idem, string action, string table, Dictionary keys, Dictionary insert, Dictionary update, CancellationToken ct) { var payload = new Dictionary { ["op"] = "UPSERT", ["table"] = table, ["keys"] = keys, ["insert"] = insert, ["update"] = update, ["expect"] = new Dictionary() }; var item = new MdpOutbox { TenantId = tenantId, TargetSourceCode = TargetSource, ActionCode = action, IdemKey = idem.Length > 200 ? idem[..200] : idem, PayloadJson = JsonSerializer.Serialize(payload) }; return await _enqueue.TryEnqueueOrRefreshAsync(item, ct, pulse: false); } private static string StatusForMasterPush(string? local) { var t = (local ?? "").Trim().ToLowerInvariant(); return t is "r" or "w" or "s" or "c" or "p" ? t : "p"; } private static string StatusForRoutingPush(string masterStatus, string? localRouting) { var m = (masterStatus ?? "").Trim().ToLowerInvariant(); if (m is "r" or "w" or "s") { var r = (localRouting ?? "").Trim().ToLowerInvariant(); return string.IsNullOrEmpty(r) ? "r" : r; } // 计划态:不写执行态;空串表示未下达 return ""; } private static Dictionary BuildMasterInsert( string domain, WorkOrdMaster m, string status, DateTime now) { var insert = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Domain"] = domain, ["WorkOrd"] = m.WorkOrd, ["ID"] = m.WorkOrd, ["Status"] = status, ["Batch"] = m.Batch ?? "", ["ItemNum"] = m.ItemNum ?? "", ["Site"] = m.Site ?? "", ["Typed"] = string.IsNullOrWhiteSpace(m.Typed) ? "" : m.Typed.Trim(), ["QtyOrded"] = m.QtyOrded, ["ProdLine"] = m.ProdLine ?? "", ["Location"] = m.Location ?? "", ["Priority"] = m.Priority, ["Urgent"] = m.Urgent, ["ItemName"] = Trunc(m.ItemName, 256), ["Department"] = Trunc(m.Department, 64), ["RoutingCode"] = m.RoutingCode ?? "", ["BOMFormula"] = m.BOMFormula ?? "", ["Project"] = m.Project ?? "", ["Drawing"] = Trunc(m.Drawing, 64), ["Rev"] = m.Rev ?? "", ["CustNo"] = Trunc(m.CustNo, 64), ["BusinessID"] = m.BusinessID, ["IsActive"] = m.IsActive, ["IsConfirm"] = m.IsConfirm, ["CreateUser"] = Trunc(m.CreateUser ?? "aidop", 24), ["UpdateUser"] = Trunc(m.UpdateUser ?? m.CreateUser ?? "aidop", 24), ["CreateTime"] = Fmt(m.CreateTime ?? now), ["UpdateTime"] = Fmt(m.UpdateTime ?? now) }; if (m.OrdDate != null) insert["OrdDate"] = Fmt(m.OrdDate); if (m.DueDate != null) insert["DueDate"] = Fmt(m.DueDate); if (m.ReleaseDate != null) insert["ReleaseDate"] = Fmt(m.ReleaseDate); return insert; } private static Dictionary BuildMasterUpdate(WorkOrdMaster m, string status, DateTime now) { var update = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Status"] = status, ["Batch"] = m.Batch ?? "", ["ItemNum"] = m.ItemNum ?? "", ["Site"] = m.Site ?? "", ["Typed"] = m.Typed ?? "", ["QtyOrded"] = m.QtyOrded, ["ProdLine"] = m.ProdLine ?? "", ["Location"] = m.Location ?? "", ["Priority"] = m.Priority, ["Urgent"] = m.Urgent, ["ItemName"] = Trunc(m.ItemName, 256), ["Department"] = Trunc(m.Department, 64), ["RoutingCode"] = m.RoutingCode ?? "", ["BOMFormula"] = m.BOMFormula ?? "", ["Project"] = m.Project ?? "", ["Drawing"] = Trunc(m.Drawing, 64), ["Rev"] = m.Rev ?? "", ["CustNo"] = Trunc(m.CustNo, 64), ["IsActive"] = m.IsActive, ["UpdateUser"] = Trunc(m.UpdateUser ?? "aidop", 24), ["UpdateTime"] = Fmt(now) }; if (m.OrdDate != null) update["OrdDate"] = Fmt(m.OrdDate); if (m.DueDate != null) update["DueDate"] = Fmt(m.DueDate); if (m.ReleaseDate != null) update["ReleaseDate"] = Fmt(m.ReleaseDate); return update; } private static Dictionary BuildDetailInsert(string domain, string workOrd, DetailRow d, DateTime now) { return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Domain"] = domain, ["ID"] = "", ["WorkOrd"] = workOrd, ["LineNum"] = d.LineNum, ["Line"] = d.LineNum, ["ItemNum"] = d.ItemNum ?? "", ["Op"] = d.Op, ["Dimension1"] = "", ["Dimension2"] = "", ["Location"] = d.Location ?? "", ["Site"] = d.Site ?? "", ["QtyRequired"] = d.QtyRequired, ["FrozenBOMQty"] = d.FrozenBOMQty, ["LotSerial"] = Trunc(d.LotSerial, 25), ["Status"] = "", ["Typed"] = d.Typed ?? "", ["UM"] = d.UM ?? "", ["IsActive"] = d.IsActive != 0, ["BusinessID"] = d.BusinessID, ["QtyReturned"] = 0m, ["CreateUser"] = Trunc(d.CreateUser ?? "aidop", 24), ["UpdateUser"] = Trunc(d.UpdateUser ?? d.CreateUser ?? "aidop", 24), ["CreateTime"] = Fmt(d.CreateTime ?? now), ["UpdateTime"] = Fmt(d.UpdateTime ?? now) }; } private static Dictionary BuildDetailUpdate(DetailRow d, DateTime now) { return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Location"] = d.Location ?? "", ["Site"] = d.Site ?? "", ["QtyRequired"] = d.QtyRequired, ["FrozenBOMQty"] = d.FrozenBOMQty, ["LotSerial"] = Trunc(d.LotSerial, 25), ["Typed"] = d.Typed ?? "", ["UM"] = d.UM ?? "", ["IsActive"] = d.IsActive != 0, ["UpdateUser"] = Trunc(d.UpdateUser ?? "aidop", 24), ["UpdateTime"] = Fmt(now) }; } private static Dictionary BuildRoutingInsert( string domain, string workOrd, RoutingRow r, string status, DateTime now) { return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Domain"] = domain, ["ID"] = "", ["WorkOrd"] = workOrd, ["OP"] = r.OP, ["ItemNum"] = r.ItemNum ?? "", ["Descr"] = Trunc(r.Descr, 256), ["MilestoneOp"] = r.MilestoneOp != 0 ? 1 : 0, ["ParentOp"] = r.ParentOp, ["QtyOrded"] = r.QtyOrded, ["Status"] = status, ["ProdLine"] = Trunc(r.ProdLine, 8), ["WorkCtr"] = Trunc(r.WorkCtr, 8), ["Machine"] = Trunc(r.Machine, 800), ["RunCrew"] = r.RunCrew, ["StdSetupTime"] = r.StdSetupTime, ["ProcessOut"] = r.ProcessOut, ["IsActive"] = r.IsActive != 0, ["CreateUser"] = Trunc(r.CreateUser ?? "aidop", 24), ["UpdateUser"] = Trunc(r.UpdateUser ?? r.CreateUser ?? "aidop", 24), ["CreateTime"] = Fmt(r.CreateTime ?? now), ["UpdateTime"] = Fmt(r.UpdateTime ?? now) }; } private static Dictionary BuildRoutingUpdate(RoutingRow r, string status, DateTime now) { return new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ItemNum"] = r.ItemNum ?? "", ["Descr"] = Trunc(r.Descr, 256), ["MilestoneOp"] = r.MilestoneOp != 0 ? 1 : 0, ["ParentOp"] = r.ParentOp, ["QtyOrded"] = r.QtyOrded, ["Status"] = status, ["ProdLine"] = Trunc(r.ProdLine, 8), ["WorkCtr"] = Trunc(r.WorkCtr, 8), ["Machine"] = Trunc(r.Machine, 800), ["RunCrew"] = r.RunCrew, ["StdSetupTime"] = r.StdSetupTime, ["ProcessOut"] = r.ProcessOut, ["IsActive"] = r.IsActive != 0, ["UpdateUser"] = Trunc(r.UpdateUser ?? "aidop", 24), ["UpdateTime"] = Fmt(now) }; } private static string Fmt(DateTime? dt) => (dt ?? DateTime.Now).ToString("yyyy-MM-dd HH:mm:ss"); private static string? Trunc(string? s, int max) { if (string.IsNullOrEmpty(s)) return s ?? ""; return s.Length <= max ? s : s[..max]; } private sealed class DetailRow { public string? Domain { get; set; } public string? WorkOrd { get; set; } public int LineNum { get; set; } public string? ItemNum { get; set; } public int Op { get; set; } public string? Location { get; set; } public string? Site { get; set; } public decimal QtyRequired { get; set; } public decimal FrozenBOMQty { get; set; } public string? LotSerial { get; set; } public string? Status { get; set; } public string? Typed { get; set; } public string? UM { get; set; } public int IsActive { get; set; } public long BusinessID { get; set; } public string? CreateUser { get; set; } public DateTime? CreateTime { get; set; } public string? UpdateUser { get; set; } public DateTime? UpdateTime { get; set; } } private sealed class RoutingRow { public string? Domain { get; set; } public string? WorkOrd { get; set; } public int OP { get; set; } public string? ItemNum { get; set; } public string? Descr { get; set; } public int MilestoneOp { get; set; } public int ParentOp { get; set; } public decimal QtyOrded { get; set; } public string? Status { get; set; } public string? ProdLine { get; set; } public string? WorkCtr { get; set; } public string? Machine { get; set; } public decimal RunCrew { get; set; } public decimal StdSetupTime { get; set; } public int ProcessOut { get; set; } public int IsActive { get; set; } public string? CreateUser { get; set; } public DateTime? CreateTime { get; set; } public string? UpdateUser { get; set; } public DateTime? UpdateTime { get; set; } } }