using System.Text.Json;
using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
using Microsoft.Extensions.Logging;
using SqlSugar;
namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
///
/// S5 IQC P3+P5:检验员提交结果后,按字段级回写 165(MissedPrint / TransHist / 报检分录 / 检验单),
/// 并对解禁箱码入队 op=PROC 调用 pr_WMS_BPM_SaveInvUpShelf @TransCode=QcCheck 完成质检库存→合格库过账。
/// 不写 PurOrdRctDetail;库存过账仅经白名单存储过程。
///
public sealed class IqcWmsResultPushService : ITransient
{
public const string TargetSource = "DOPDEMORQ_SQLSERVER";
public const string ActionMp = "IQC_WMS_MP";
public const string ActionMpHist = "IQC_WMS_MP_HIST";
public const string ActionEntry = "IQC_WMS_ENTRY";
public const string ActionBill = "IQC_WMS_BILL";
public const string ActionUpShelf = "IQC_WMS_UPSHELF";
public const string QmsApiAction = "S5_IQC_RESULT_PUSH";
private readonly ISqlSugarClient _db;
private readonly MdpOutboxEnqueueService _enqueue;
private readonly MdpOutboxWakeSignal _wake;
private readonly ILogger _logger;
public IqcWmsResultPushService(
ISqlSugarClient db,
MdpOutboxEnqueueService enqueue,
MdpOutboxWakeSignal wake,
ILogger logger)
{
_db = db;
_enqueue = enqueue;
_wake = wake;
_logger = logger;
}
///
/// 提交结果后入队;失败不抛(不阻断主流程)。同时保留 QMS_API 占位分支。
///
public async Task TryEnqueueAfterSubmitAsync(long billId, long tenantId, CancellationToken ct = default)
{
try
{
await EnqueueCoreAsync(billId, tenantId, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[IqcWmsPush] 入队失败 billId={BillId}", billId);
}
}
private async Task EnqueueCoreAsync(long billId, long tenantId, CancellationToken ct)
{
var bill = await _db.Ado.SqlQuerySingleAsync(
"""
SELECT
id AS Id, FBILLNO AS BillNo, lydjbh AS ApplyBillNo, hid AS Hid,
pd AS Pd, dhsl AS Dhsl, bhgsl AS Bhgsl, clfs AS Clfs,
FMATERIALCFG AS MaterialCode, jyr AS Inspector
FROM qms_qcp_inspbill
WHERE id = @Id AND tenant_id = @TenantId
LIMIT 1
""",
new SugarParameter("@Id", billId),
new SugarParameter("@TenantId", tenantId));
if (bill == null) return;
// 保留 QMS_API 占位(真实地址落地后可双投)
await TryEnqueueQmsApiAsync(bill, tenantId, ct);
var entry = await _db.Ado.SqlQuerySingleAsync(
"""
SELECT
id AS Id, FSRCORDERNUM AS Receiver, FMATERIALCFG AS ItemNum,
FAPPLYQTY AS ApplyQty
FROM qms_qcp_insappnentry
WHERE id = @Hid AND tenant_id = @TenantId
LIMIT 1
""",
new SugarParameter("@Hid", bill.Hid ?? 0),
new SugarParameter("@TenantId", tenantId));
if (entry == null || string.IsNullOrWhiteSpace(entry.Receiver) || string.IsNullOrWhiteSpace(entry.ItemNum))
{
_logger.LogWarning(
"[IqcWmsPush] 报检分录缺失 Receiver/ItemNum,跳过 165 推送 bill={BillNo}", bill.BillNo);
return;
}
var domain = "8010";
var qcNbr = string.IsNullOrWhiteSpace(bill.ApplyBillNo) ? bill.BillNo : bill.ApplyBillNo!;
var userNo = string.IsNullOrWhiteSpace(bill.Inspector) ? "AIDOP" : bill.Inspector!;
var now = DateTime.Now;
// Ai-DOP clfs(0让步/1挑选/2报废) → 旧 DOP MRB 矩阵
var (status, qtyAccFactor, qtyRejectFactor, resultDescr) = MapResult(bill.Pd, bill.Clfs);
// 入队前预检:本库箱码(已与 165 同步)
var barcodes = await _db.Ado.SqlQueryAsync(
"""
SELECT BarCode, Qty, Status, IFNULL(Domain,'8010') AS Domain
FROM MissedPrint
WHERE IFNULL(Domain,'') IN ('', @Domain)
AND RctNbr = @Receiver AND ItemNum = @ItemNum
AND IFNULL(Status,'') <> 'C'
AND IFNULL(Type,'') <> 'Card'
""",
new SugarParameter("@Domain", domain),
new SugarParameter("@Receiver", entry.Receiver),
new SugarParameter("@ItemNum", entry.ItemNum));
if (barcodes == null || barcodes.Count == 0)
{
_logger.LogWarning(
"[IqcWmsPush] 无箱码可推 Receiver={Receiver} Item={Item}", entry.Receiver, entry.ItemNum);
return;
}
var sumQty = barcodes.Sum(x => x.Qty);
var qtyReceived = await _db.Ado.SqlQuerySingleAsync(
"""
SELECT SUM(IFNULL(QtyReceived,0))
FROM PurOrdRctDetail
WHERE IFNULL(Domain,'') IN ('', @Domain)
AND Receiver = @Receiver AND ItemNum = @ItemNum
""",
new SugarParameter("@Domain", domain),
new SugarParameter("@Receiver", entry.Receiver),
new SugarParameter("@ItemNum", entry.ItemNum)) ?? 0m;
// 数量平衡:用检验数量口径(合格分支 dhsl 应等于收货数)
var checkQty = (bill.Dhsl ?? 0m) + (bill.Bhgsl ?? 0m);
if (checkQty <= 0) checkQty = entry.ApplyQty ?? 0m;
if (qtyReceived > 0 && checkQty > 0 && checkQty != qtyReceived)
{
_logger.LogWarning(
"[IqcWmsPush] 数量不平衡 check={Check} received={Recv},仍尝试推送(箱码侧)",
checkQty, qtyReceived);
}
if (sumQty > 0 && qtyReceived > 0 && sumQty != qtyReceived)
{
_logger.LogWarning(
"[IqcWmsPush] 箱码总数与收货数不一致 sum={Sum} received={Recv}", sumQty, qtyReceived);
}
// 待上架箱码(N=合格库 / R=不良品库);KEEP 不解禁也不上架
var upShelfTargets = new List<(BarcodeRow Bc, string NewStatus)>();
foreach (var bc in barcodes)
{
if (string.IsNullOrWhiteSpace(bc.BarCode)) continue;
var qtyAccN = qtyAccFactor ? bc.Qty : 0m;
var qtyRejectN = qtyRejectFactor ? bc.Qty : 0m;
var newStatus = status;
// 挑选且无箱码清单:旧系统不解禁任何箱码 → 保持原状态
if (status == "KEEP") newStatus = string.IsNullOrWhiteSpace(bc.Status) ? "I" : bc.Status!;
var mpPayload = JsonSerializer.Serialize(new
{
op = "UPDATE",
table = "MissedPrint",
keys = new Dictionary
{
["Domain"] = string.IsNullOrWhiteSpace(bc.Domain) ? domain : bc.Domain,
["BarCode"] = bc.BarCode
},
update = new Dictionary
{
["Status"] = newStatus,
["QtyAccN"] = qtyAccN,
["QtyRejectN"] = qtyRejectN,
["UpdateUser"] = userNo,
["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss")
}
});
await _enqueue.TryEnqueueAsync(new MdpOutbox
{
TenantId = tenantId,
TargetSourceCode = TargetSource,
ActionCode = ActionMp,
IdemKey = $"iqc|mp|{qcNbr}|{bc.BarCode}",
PayloadJson = mpPayload
}, ct, pulse: false);
var histPayload = JsonSerializer.Serialize(new
{
op = "INSERT",
table = "MissedPrintTransHist",
keys = new Dictionary
{
["Domain"] = string.IsNullOrWhiteSpace(bc.Domain) ? domain : bc.Domain,
["BarCode"] = bc.BarCode,
["TransType"] = "来料检验-通过",
["CreateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss.fff")
},
insert = new Dictionary
{
["Domain"] = string.IsNullOrWhiteSpace(bc.Domain) ? domain : bc.Domain,
["ItemNum"] = entry.ItemNum,
["BarCode"] = bc.BarCode,
["Qty"] = bc.Qty,
["OrdNbr"] = null,
["Remark"] = "来料检验单:" + qcNbr,
["RctNbr"] = entry.Receiver,
["Status"] = newStatus,
["TransType"] = bill.Pd == 0 ? "来料检验-通过" : $"来料检验-{resultDescr}",
["CreateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss"),
["CreateUser"] = userNo,
["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss"),
["UpdateUser"] = userNo
}
});
await _enqueue.TryEnqueueAsync(new MdpOutbox
{
TenantId = tenantId,
TargetSourceCode = TargetSource,
ActionCode = ActionMpHist,
IdemKey = $"iqc|mph|{qcNbr}|{bc.BarCode}|{now:yyyyMMddHHmmss}",
PayloadJson = histPayload
}, ct, pulse: false);
if (newStatus is "N" or "R")
upShelfTargets.Add((bc, newStatus));
}
// 报检分录状态
var entryPayload = JsonSerializer.Serialize(new
{
op = "UPDATE",
table = "qms_qcp_insappnentry",
keys = new Dictionary { ["id"] = entry.Id },
update = new Dictionary
{
["FINSPECTSTATUS"] = "检验完成",
["jywcsj"] = now.ToString("yyyy-MM-dd HH:mm:ss")
}
});
await _enqueue.TryEnqueueAsync(new MdpOutbox
{
TenantId = tenantId,
TargetSourceCode = TargetSource,
ActionCode = ActionEntry,
IdemKey = $"iqc|entry|{entry.Id}|{qcNbr}",
PayloadJson = entryPayload
}, ct, pulse: false);
// 检验单推 165(含本库 id;用 FBILLNO 做自然键 UPSERT)
var billPayload = JsonSerializer.Serialize(new
{
op = "UPSERT",
table = "qms_qcp_inspbill",
keys = new Dictionary { ["FBILLNO"] = bill.BillNo },
insert = new Dictionary
{
["id"] = bill.Id,
["FBILLNO"] = bill.BillNo,
["lydjbh"] = bill.ApplyBillNo,
["hid"] = entry.Id,
["FMATERIALCFG"] = bill.MaterialCode,
["pd"] = bill.Pd,
["dhsl"] = bill.Dhsl,
["bhgsl"] = bill.Bhgsl,
["clfs"] = bill.Clfs,
["jyr"] = bill.Inspector,
["FRINSQTY"] = entry.ApplyQty
},
update = new Dictionary
{
["pd"] = bill.Pd,
["dhsl"] = bill.Dhsl,
["bhgsl"] = bill.Bhgsl,
["clfs"] = bill.Clfs,
["jyr"] = bill.Inspector
}
});
// 无上架候选时由 bill 唤醒;有候选时由最后一条 UPSHELF 唤醒
await _enqueue.TryEnqueueAsync(new MdpOutbox
{
TenantId = tenantId,
TargetSourceCode = TargetSource,
ActionCode = ActionBill,
IdemKey = $"iqc|bill|{bill.BillNo}",
PayloadJson = billPayload
}, ct, pulse: upShelfTargets.Count == 0);
// 本库同步置检验完成(写回过程在旧系统自动写;Ai-DOP 权威侧先落本地)
await _db.Ado.ExecuteCommandAsync(
"""
UPDATE qms_qcp_insappnentry
SET FINSPECTSTATUS = '检验完成', jywcsj = @Now
WHERE id = @Id AND tenant_id = @TenantId
""",
new SugarParameter("@Now", now.ToString("yyyy-MM-dd HH:mm:ss")),
new SugarParameter("@Id", entry.Id),
new SugarParameter("@TenantId", tenantId));
var upShelfEnqueued = await EnqueueUpShelfAsync(
upShelfTargets, entry, domain, qcNbr!, userNo, tenantId, ct);
// 有候选但全部跳过(暂收 / 无默认库位)时 bill 未 pulse,补唤醒
if (upShelfTargets.Count > 0 && upShelfEnqueued == 0)
_wake.Pulse();
}
///
/// 合格/让步 → ItemMaster.Location;退货 → PurOrdControl.RejectInspectionLocation(165 resolve)。
/// 暂收路径(ReceiptQty>0)不入队,避免与 165 IQC 自带上架重复(任务书 U-F)。
///
/// 实际入队条数。
private async Task EnqueueUpShelfAsync(
List<(BarcodeRow Bc, string NewStatus)> targets,
EntryRow entry,
string domain,
string qcNbr,
string userNo,
long tenantId,
CancellationToken ct)
{
if (targets.Count == 0) return 0;
var receiptQty = await _db.Ado.SqlQuerySingleAsync(
"""
SELECT SUM(IFNULL(d.ReceiptQty, 0))
FROM PurOrdRctDetail r
INNER JOIN PurOrdDetail d
ON IFNULL(d.Domain,'') IN ('', @Domain)
AND d.PurOrd = r.OrdNbr AND d.Line = r.OrdLine
WHERE IFNULL(r.Domain,'') IN ('', @Domain)
AND r.Receiver = @Receiver AND r.ItemNum = @ItemNum
""",
new SugarParameter("@Domain", domain),
new SugarParameter("@Receiver", entry.Receiver!),
new SugarParameter("@ItemNum", entry.ItemNum!)) ?? 0m;
if (receiptQty > 0)
{
_logger.LogWarning(
"[IqcWmsPush] ReceiptQty={Qty}>0(暂收路径),跳过 QcCheck 上架入队 Receiver={Receiver}",
receiptQty, entry.Receiver);
return 0;
}
string? passShelf = null;
if (targets.Any(t => t.NewStatus == "N"))
{
passShelf = await _db.Ado.SqlQuerySingleAsync(
"""
SELECT Location
FROM ItemMaster
WHERE ItemNum = @ItemNum
AND IFNULL(Domain,'') IN ('', @Domain)
LIMIT 1
""",
new SugarParameter("@ItemNum", entry.ItemNum!),
new SugarParameter("@Domain", domain));
if (string.IsNullOrWhiteSpace(passShelf))
{
_logger.LogWarning(
"[IqcWmsPush] ItemMaster.Location 为空,跳过合格品上架 Item={Item}", entry.ItemNum);
}
}
var enqueued = 0;
var pendingPulse = false;
for (var i = 0; i < targets.Count; i++)
{
var (bc, newStatus) = targets[i];
var bcDomain = string.IsNullOrWhiteSpace(bc.Domain) ? domain : bc.Domain!;
Dictionary args;
Dictionary? resolve;
if (newStatus == "N")
{
if (string.IsNullOrWhiteSpace(passShelf)) continue;
args = new Dictionary
{
["Domain"] = bcDomain,
["Shelf"] = passShelf,
["TransCode"] = "QcCheck",
["UserNo"] = userNo,
["IsProcCall"] = 1
};
resolve = new Dictionary
{
["Details"] = new Dictionary
{
["table"] = "MissedPrint",
["column"] = "RecID",
["match"] = new Dictionary
{
["Domain"] = bcDomain,
["BarCode"] = bc.BarCode
}
}
};
}
else // R:不良品库位由 165 PurOrdControl 现查
{
args = new Dictionary
{
["Domain"] = bcDomain,
["TransCode"] = "QcCheck",
["UserNo"] = userNo,
["IsProcCall"] = 1
};
resolve = new Dictionary
{
["Details"] = new Dictionary
{
["table"] = "MissedPrint",
["column"] = "RecID",
["match"] = new Dictionary
{
["Domain"] = bcDomain,
["BarCode"] = bc.BarCode
}
},
["Shelf"] = new Dictionary
{
["table"] = "PurOrdControl",
["column"] = "RejectInspectionLocation",
["match"] = new Dictionary { ["Domain"] = bcDomain }
}
};
}
var payload = JsonSerializer.Serialize(new
{
op = "PROC",
proc = "pr_WMS_BPM_SaveInvUpShelf",
args,
resolve,
output = "ReturnMsg",
success = "成功",
idempotent = new[] { "已是此货架", "已是此位置" }
});
// 先不 pulse,循环结束后对最后成功入队的一条补唤醒(避免中间 continue 导致未 pulse)
var ok = await _enqueue.TryEnqueueAsync(new MdpOutbox
{
TenantId = tenantId,
TargetSourceCode = TargetSource,
ActionCode = ActionUpShelf,
IdemKey = $"iqc|upshelf|{qcNbr}|{bc.BarCode}",
PayloadJson = payload
}, ct, pulse: false);
if (ok)
{
enqueued++;
pendingPulse = true;
}
}
if (pendingPulse) _wake.Pulse();
return enqueued;
}
private async Task TryEnqueueQmsApiAsync(BillRow bill, long tenantId, CancellationToken ct)
{
var billNo = string.IsNullOrWhiteSpace(bill.BillNo) ? bill.Id.ToString() : bill.BillNo!;
var payload = JsonSerializer.Serialize(new
{
path = "/iqc/result",
method = "POST",
body = new
{
billId = bill.Id,
billNo,
pd = bill.Pd,
dhsl = bill.Dhsl,
bhgsl = bill.Bhgsl,
action = QmsApiAction
}
});
await _enqueue.TryEnqueueAsync(new MdpOutbox
{
TenantId = tenantId,
TargetSourceCode = "QMS_API",
ActionCode = QmsApiAction,
IdemKey = billNo,
PayloadJson = payload
}, ct, pulse: false);
}
///
/// 返回 (箱码Status, 是否QtyAccN=Qty, 是否QtyRejectN=Qty, 描述)。
/// Status=KEEP 表示挑选且无箱码清单时保持原状态。
///
private static (string Status, bool QtyAcc, bool QtyReject, string Descr) MapResult(int? pd, long? clfs)
{
if (pd == 0)
return ("N", true, false, "通过");
// Ai-DOP:0让步 / 1挑选 / 2报废
return clfs switch
{
0 => ("N", true, false, "MRB让步接收"),
1 => ("KEEP", false, false, "MRB挑选使用"),
2 => ("R", false, true, "MRB退货"),
_ => ("R", false, true, "退货")
};
}
private sealed class BillRow
{
public long Id { get; set; }
public string? BillNo { get; set; }
public string? ApplyBillNo { get; set; }
public long? Hid { get; set; }
public int? Pd { get; set; }
public decimal? Dhsl { get; set; }
public decimal? Bhgsl { get; set; }
public long? Clfs { get; set; }
public string? MaterialCode { get; set; }
public string? Inspector { get; set; }
}
private sealed class EntryRow
{
public long Id { get; set; }
public string? Receiver { get; set; }
public string? ItemNum { get; set; }
public decimal? ApplyQty { get; set; }
}
private sealed class BarcodeRow
{
public string? BarCode { get; set; }
public decimal Qty { get; set; }
public string? Status { get; set; }
public string? Domain { get; set; }
}
}