Bläddra i källkod

feat(s4): read WMS receipt results back into Ai-DOP (server 1.0.331)

扫码收货的结果此前只留在 165,Ai-DOP 侧的采购明细、箱码与送货单状态一直停在发货时的快照。
复用既有热回读底座补上采购单方向:装箱标签推送后登记 PUR_ORDER 关注,轮询发现变更即把 165
的权威列写回本库。

- MdpHotWatchService 新增 EnrollPurOrderAsync 与 ApplyPurchaseReceiptAsync,回写
  PurOrdDetail 的 RctQty/ReceiptQty/QtyReturned,以及 MissedPrint 的箱码状态、收货单号、
  库位货架;ShouldTerminateAsync 补采购单终结分支,收满或订单关闭即停止轮询
- 送货单状态按箱码推进(待收/收货中/完成)。165 扫码收货只入待检仓,故本次不写 rksl,
  合格入库数留给检验环节
- scm_shd.id 与 scm_shdzb.glid 排序规则不一致,直接 JOIN 会报 Illegal mix of collations,
  改为先取表头再按参数更新
- 本库自建单不填 Domain,明细回写放宽为空 Domain 亦命中,否则匹配不到任何行
- 修复 csproj 中 </Description> 闭合尖括号缺失导致后端无法编译的问题(0da9ce11b 引入)

实测 PO202608110003 部分收货:明细 RctQty 回读 500,箱码转待检并带回收货单号与库位,
送货单转收货中。
YY968XX 1 dag sedan
förälder
incheckning
b58e50dfd3

+ 4 - 4
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -10,10 +10,10 @@
     <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
-    <Description>Admin.NET 閫氱敤鏉冮檺寮€鍙戝钩鍙?/Description>
-    <AssemblyVersion>1.0.330</AssemblyVersion>
-    <FileVersion>1.0.330</FileVersion>
-    <Version>1.0.330</Version>
+    <Description>Admin.NET 通用权限开发平台</Description>
+    <AssemblyVersion>1.0.331</AssemblyVersion>
+    <FileVersion>1.0.331</FileVersion>
+    <Version>1.0.331</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 242 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs

@@ -129,6 +129,30 @@ public sealed class MdpHotWatchService : ITransient
         }
     }
 
+    /// <summary>
+    /// 装箱标签推入 165 后登记采购单热关注,用于回读 WMS 扫码收货结果。
+    ///
+    /// 挂在标签环节而非建单环节:标签生成意味着即将到货,此时开始轮询窗口最短。
+    /// </summary>
+    public async Task EnrollPurOrderAsync(
+        string domain, string purOrd, long tenantId = 0, CancellationToken ct = default)
+    {
+        purOrd = (purOrd ?? "").Trim();
+        if (string.IsNullOrWhiteSpace(purOrd)) return;
+        domain = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim();
+
+        if (tenantId <= 0)
+            tenantId = await ResolveLocalPurOrdTenantAsync(purOrd, domain, ct);
+
+        await EnrollAsync(
+            "PUR_ORDER",
+            purOrd,
+            domain,
+            new[] { "PurOrdMaster", "PurOrdDetail", "MissedPrint" },
+            tenantId,
+            ct: ct);
+    }
+
     private static bool TryParseWoIdem(string? idem, out string domain, out string workOrd)
     {
         domain = "8010";
@@ -246,6 +270,18 @@ public sealed class MdpHotWatchService : ITransient
                             effectiveTenantId, watch.Domain, watch.BizKey, periodDets, routings, ct);
                     }
 
+                    // 采购收货结果写回本库业务表,供发货单列表与齐套口径直接读取
+                    if (string.Equals(watch.BizType, "PUR_ORDER", StringComparison.OrdinalIgnoreCase))
+                    {
+                        tableRows.TryGetValue("PurOrdDetail", out var purDetails);
+                        tableRows.TryGetValue("MissedPrint", out var barcodes);
+                        var effectiveTenantId = watch.TenantId;
+                        if (effectiveTenantId <= 0)
+                            effectiveTenantId = await ResolveLocalPurOrdTenantAsync(watch.BizKey, watch.Domain, ct);
+                        await ApplyPurchaseReceiptAsync(
+                            effectiveTenantId, watch.Domain, watch.BizKey, purDetails, barcodes, ct);
+                    }
+
                     if (await ShouldTerminateAsync(remote, watch, ct))
                     {
                         watch.Status = 1;
@@ -542,6 +578,189 @@ public sealed class MdpHotWatchService : ITransient
             workOrd, domain, grouped.Count, periodDets.Count);
     }
 
+    /// <summary>
+    /// 把 165 的采购收货结果回读本库:明细收货数、箱码状态,并据箱码推进送货单状态。
+    ///
+    /// 口径(2026-08-11 定):WMS 扫码收货只入待检仓(InvTransHist 落 rct-po-ins / Loc=1000),
+    /// 不等于合格入库,所以这里只推进 scm_shd/scm_shdzb.shzt,不写 rksl——合格入库数留给检验环节。
+    /// 165 的 scm_shdzb.rksl / shzt 在收货时并不更新,送货单进度只能由箱码状态反推。
+    /// </summary>
+    private async Task ApplyPurchaseReceiptAsync(
+        long tenantId,
+        string domain,
+        string purOrd,
+        List<Dictionary<string, object>>? purDetails,
+        List<Dictionary<string, object>>? barcodes,
+        CancellationToken ct)
+    {
+        var now = DateTime.Now;
+        var updatedLines = 0;
+        var updatedBarcodes = 0;
+
+        if (purDetails != null)
+        {
+            foreach (var row in purDetails)
+            {
+                if (!TryGetInt(row, "Line", out var line))
+                    continue;
+
+                // 自建单本库不填 Domain(165/MES 概念),所以空 Domain 也算命中;采购单号本库唯一
+                updatedLines += await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE PurOrdDetail
+                    SET RctQty = @RctQty,
+                        ReceiptQty = @ReceiptQty,
+                        QtyReturned = @QtyReturned,
+                        UpdateUser = 'MDP_HOT',
+                        UpdateTime = @Now
+                    WHERE PurOrd = @PurOrd
+                      AND Line = @Line
+                      AND IFNULL(Domain, '') IN ('', @Domain)
+                      AND (@TenantId = 0 OR IFNULL(tenant_id, 0) = @TenantId)
+                    """,
+                    new SugarParameter("@RctQty", GetDecimal(row, "RctQty")),
+                    new SugarParameter("@ReceiptQty", GetDecimal(row, "ReceiptQty")),
+                    new SugarParameter("@QtyReturned", GetDecimal(row, "QtyReturned")),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@PurOrd", purOrd),
+                    new SugarParameter("@Line", line),
+                    new SugarParameter("@Domain", domain),
+                    new SugarParameter("@TenantId", tenantId));
+            }
+        }
+
+        // 本库 MissedPrint 无 tenant_id,按 Domain + BarCode 定位;作废行 BarCode 带 RecID 前缀不会误匹配
+        var shippers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        if (barcodes != null)
+        {
+            foreach (var row in barcodes)
+            {
+                var barCode = GetString(row, "BarCode")?.Trim();
+                if (string.IsNullOrWhiteSpace(barCode))
+                    continue;
+
+                updatedBarcodes += await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE MissedPrint
+                    SET Status = CASE WHEN IFNULL(@Status, '') = '' THEN Status ELSE @Status END,
+                        RctNbr = @RctNbr,
+                        Location = @Location,
+                        Shelf = @Shelf,
+                        InvStatus = @InvStatus,
+                        UpdateUser = 'MDP_HOT',
+                        UpdateTime = @Now
+                    WHERE BarCode = @BarCode
+                      AND IFNULL(Domain, '') IN ('', @Domain)
+                    """,
+                    new SugarParameter("@Status", Trunc(GetString(row, "Status"), 4) ?? ""),
+                    new SugarParameter("@RctNbr", Trunc(GetString(row, "RctNbr"), 48) ?? ""),
+                    new SugarParameter("@Location", Trunc(GetString(row, "Location"), 24) ?? ""),
+                    new SugarParameter("@Shelf", Trunc(GetString(row, "Shelf"), 40) ?? ""),
+                    new SugarParameter("@InvStatus", Trunc(GetString(row, "InvStatus"), 24) ?? ""),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@BarCode", barCode),
+                    new SugarParameter("@Domain", domain));
+
+                var shipper = GetString(row, "ShipperNbr")?.Trim();
+                if (!string.IsNullOrWhiteSpace(shipper))
+                    shippers.Add(shipper!);
+            }
+        }
+
+        foreach (var shipper in shippers)
+            await ApplyShipmentStatusAsync(tenantId, domain, shipper, ct);
+
+        _logger.LogInformation(
+            "[MdpHotWatch] applied PUR_ORDER receipt po={PurOrd} domain={Domain} lines={Lines} barcodes={Barcodes} shipments={Shipments}",
+            purOrd, domain, updatedLines, updatedBarcodes, shippers.Count);
+    }
+
+    /// <summary>
+    /// 按箱码待收数推进送货单状态:全部待收=待收,全部离开待收=完成,其余=收货中。
+    ///
+    /// 完成判据取箱码而非数量(决策:少收/超收不影响状态),列表读的是 scm_shd.shzt。
+    /// </summary>
+    private async Task ApplyShipmentStatusAsync(
+        long tenantId, string domain, string shddh, CancellationToken ct)
+    {
+        var stats = await _db.Ado.SqlQueryAsync<ShipmentBarcodeStat>(
+            """
+            SELECT
+                COUNT(*) AS Total,
+                SUM(CASE WHEN IFNULL(Status, '') = 'U' THEN 1 ELSE 0 END) AS Pending
+            FROM MissedPrint
+            WHERE ShipperNbr = @Shddh
+              AND IFNULL(Domain, '') = @Domain
+              AND IFNULL(PurOrd, '') NOT LIKE '作废%'
+            """,
+            new SugarParameter("@Shddh", shddh),
+            new SugarParameter("@Domain", domain));
+
+        var stat = stats?.FirstOrDefault();
+        if (stat == null || stat.Total <= 0)
+            return;
+
+        var shzt = stat.Pending >= stat.Total ? "待收" : (stat.Pending == 0 ? "完成" : "收货中");
+
+        // 先定位表头再按主键更新:scm_shd.id 与 scm_shdzb.glid 排序规则不同(0900_ai_ci vs unicode_ci),
+        // 直接 JOIN 会报 Illegal mix of collations,改由参数传 glid 规避
+        var heads = await _db.Ado.SqlQueryAsync<ShipmentHeadRow>(
+            """
+            SELECT id AS Id
+            FROM scm_shd
+            WHERE shddh = @Shddh
+              AND (@TenantId = 0 OR IFNULL(tenant_id, 0) = @TenantId)
+            ORDER BY id
+            LIMIT 1
+            """,
+            new SugarParameter("@Shddh", shddh),
+            new SugarParameter("@TenantId", tenantId));
+
+        var head = heads?.FirstOrDefault();
+        if (head == null)
+        {
+            _logger.LogWarning(
+                "[MdpHotWatch] 送货单在本库缺失,跳过状态推进 shddh={Shddh} tenant={Tenant}", shddh, tenantId);
+            return;
+        }
+
+        await _db.Ado.ExecuteCommandAsync(
+            "UPDATE scm_shd SET shzt = @Shzt WHERE id = @Id",
+            new SugarParameter("@Shzt", shzt),
+            new SugarParameter("@Id", head.Id));
+
+        await _db.Ado.ExecuteCommandAsync(
+            "UPDATE scm_shdzb SET shzt = @Shzt WHERE glid = @Glid",
+            new SugarParameter("@Shzt", shzt),
+            new SugarParameter("@Glid", head.Id.ToString()));
+    }
+
+    private sealed class ShipmentBarcodeStat
+    {
+        public int Total { get; set; }
+        public int Pending { get; set; }
+    }
+
+    private sealed class ShipmentHeadRow
+    {
+        public long Id { get; set; }
+    }
+
+    private async Task<long> ResolveLocalPurOrdTenantAsync(string purOrd, string domain, CancellationToken ct)
+    {
+        var tid = await _db.Ado.SqlQuerySingleAsync<long?>(
+            """
+            SELECT IFNULL(tenant_id, 0)
+            FROM PurOrdDetail
+            WHERE PurOrd = @PurOrd AND IFNULL(Domain, '') IN ('', @Domain)
+            ORDER BY Line
+            LIMIT 1
+            """,
+            new SugarParameter("@PurOrd", purOrd),
+            new SugarParameter("@Domain", domain));
+        return tid ?? 0;
+    }
+
     private sealed class PsdOpActual
     {
         public int Op { get; set; }
@@ -621,6 +840,29 @@ public sealed class MdpHotWatchService : ITransient
                 new SugarParameter("@k", watch.BizKey));
             return string.Equals(status?.ToString()?.Trim(), "C", StringComparison.OrdinalIgnoreCase);
         }
+        if (string.Equals(watch.BizType, "PUR_ORDER", StringComparison.OrdinalIgnoreCase))
+        {
+            var status = await remote.Ado.GetScalarAsync(
+                "SELECT TOP 1 Status FROM PurOrdMaster WHERE Domain=@d AND PurOrd=@k",
+                new SugarParameter("@d", watch.Domain),
+                new SugarParameter("@k", watch.BizKey));
+            if (string.Equals(status?.ToString()?.Trim(), "C", StringComparison.OrdinalIgnoreCase))
+                return true;
+
+            // 箱码全部离开待收即收货结束;标签尚未推达(total=0)时不能终结
+            var total = Convert.ToInt32(await remote.Ado.GetScalarAsync(
+                "SELECT COUNT(*) FROM MissedPrint WHERE Domain=@d AND OrdNbr=@k",
+                new SugarParameter("@d", watch.Domain),
+                new SugarParameter("@k", watch.BizKey)) ?? 0);
+            if (total > 0)
+            {
+                var pending = Convert.ToInt32(await remote.Ado.GetScalarAsync(
+                    "SELECT COUNT(*) FROM MissedPrint WHERE Domain=@d AND OrdNbr=@k AND IsNull(Status,'')='U'",
+                    new SugarParameter("@d", watch.Domain),
+                    new SugarParameter("@k", watch.BizKey)) ?? 0);
+                if (pending == 0) return true;
+            }
+        }
         // 兜底:超过 7 天强制终结
         return watch.EnrollTime < DateTime.Now.AddDays(-7);
     }

+ 20 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/PurOrdWmsPushService.cs

@@ -1,5 +1,6 @@
 using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Admin.NET.Plugin.AiDOP.DataPlatform.HotWatch;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
@@ -30,6 +31,7 @@ public class PurOrdWmsPushService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly MdpOutboxEnqueueService _enqueue;
     private readonly MdpOutboxWakeSignal _wake;
+    private readonly MdpHotWatchService _hotWatch;
     private readonly UserManager _userManager;
     private readonly IHttpContextAccessor _httpContextAccessor;
     private readonly ILogger<PurOrdWmsPushService> _logger;
@@ -38,6 +40,7 @@ public class PurOrdWmsPushService : IDynamicApiController, ITransient
         ISqlSugarClient db,
         MdpOutboxEnqueueService enqueue,
         MdpOutboxWakeSignal wake,
+        MdpHotWatchService hotWatch,
         UserManager userManager,
         IHttpContextAccessor httpContextAccessor,
         ILogger<PurOrdWmsPushService> logger)
@@ -45,6 +48,7 @@ public class PurOrdWmsPushService : IDynamicApiController, ITransient
         _db = db;
         _enqueue = enqueue;
         _wake = wake;
+        _hotWatch = hotWatch;
         _userManager = userManager;
         _httpContextAccessor = httpContextAccessor;
         _logger = logger;
@@ -760,6 +764,7 @@ public class PurOrdWmsPushService : IDynamicApiController, ITransient
             new SugarParameter("@Shddh", shddh));
 
         var now = DateTime.Now;
+        var watchKeys = new HashSet<(string Domain, string PurOrd)>();
         foreach (var b in rows)
         {
             var domain = string.IsNullOrWhiteSpace(b.Domain) ? "8010" : b.Domain;
@@ -837,6 +842,21 @@ public class PurOrdWmsPushService : IDynamicApiController, ITransient
                     keys, insert, update, null, ct))
                 result.Enqueued++;
             result.BarcodeCount++;
+
+            if (!string.IsNullOrWhiteSpace(b.PurOrd))
+                watchKeys.Add((domain, b.PurOrd.Trim()));
+        }
+
+        // 标签已推 → 登记热关注,回读 WMS 收货结果(收货数、箱码状态、送货单状态)
+        try
+        {
+            foreach (var (domain, purOrd) in watchKeys)
+                await _hotWatch.EnrollPurOrderAsync(domain, purOrd, tenantId, ct);
+        }
+        catch (Exception ex)
+        {
+            // 登记失败不应影响标签推送本身,下次生成标签会再登记一次
+            _logger.LogWarning(ex, "[PurOrdWmsPush] 登记采购单热关注失败 shddh={Shddh}", shddh);
         }
     }