PickBillNbrSyncService.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. using System.Text.Json;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform.HotWatch;
  4. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  5. using Microsoft.Extensions.Logging;
  6. using Microsoft.Extensions.Options;
  7. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
  8. /// <summary>
  9. /// UAT-INT-001 · Mes 模式:工单物料变更后入队 Outbox,推送时在 165 本地事务内同步一张有效 SM。
  10. /// Local 模式不入队,由调用方继续写本库 Nbr*。
  11. /// </summary>
  12. public sealed class PickBillNbrSyncService : ITransient
  13. {
  14. public const string ActionCode = "PICK_NBR_SYNC";
  15. public const string TargetSource = CreatePickBillService.TargetSource;
  16. private readonly ISqlSugarClient _db;
  17. private readonly MdpSourceScopeFactory _scopeFactory;
  18. private readonly MdpOutboxEnqueueService _enqueue;
  19. private readonly MdpHotWatchService _hotWatch;
  20. private readonly AidopPickBillOptions _opt;
  21. private readonly ILogger<PickBillNbrSyncService> _logger;
  22. public PickBillNbrSyncService(
  23. ISqlSugarClient db,
  24. MdpSourceScopeFactory scopeFactory,
  25. MdpOutboxEnqueueService enqueue,
  26. MdpHotWatchService hotWatch,
  27. IOptions<AidopPickBillOptions> opt,
  28. ILogger<PickBillNbrSyncService> logger)
  29. {
  30. _db = db;
  31. _scopeFactory = scopeFactory;
  32. _enqueue = enqueue;
  33. _hotWatch = hotWatch;
  34. _opt = opt.Value;
  35. _logger = logger;
  36. }
  37. public bool IsMes => _opt.IsMes;
  38. /// <summary>
  39. /// Mes 且工单已下达/投产/暂停时入队(同键刷新 payload)。Local 或未下达返回 false。
  40. /// </summary>
  41. public async Task<bool> TryEnqueueAfterPlanChangeAsync(
  42. long tenantId, string workOrd, string? domain, string account, CancellationToken ct = default)
  43. {
  44. if (!_opt.IsMes)
  45. return false;
  46. workOrd = (workOrd ?? "").Trim();
  47. if (string.IsNullOrWhiteSpace(workOrd))
  48. return false;
  49. var status = await _db.Ado.GetStringAsync(
  50. """
  51. SELECT IFNULL(LOWER(TRIM(Status)), '') FROM WorkOrdMaster
  52. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  53. LIMIT 1
  54. """,
  55. new SugarParameter("@TenantId", tenantId),
  56. new SugarParameter("@WorkOrd", workOrd));
  57. if (status is not ("r" or "w" or "s"))
  58. return false;
  59. var mesDomain = ResolveMesDomain(domain);
  60. var idem = $"pick|{mesDomain}|{workOrd}|sm-sync";
  61. if (idem.Length > 200)
  62. idem = idem[..200];
  63. var item = new MdpOutbox
  64. {
  65. TenantId = tenantId,
  66. TargetSourceCode = TargetSource,
  67. ActionCode = ActionCode,
  68. IdemKey = idem,
  69. PayloadJson = JsonSerializer.Serialize(new PickNbrSyncPayload
  70. {
  71. Domain = mesDomain,
  72. WorkOrd = workOrd,
  73. Account = string.IsNullOrWhiteSpace(account) ? "aidop" : account.Trim()
  74. })
  75. };
  76. var ok = await _enqueue.TryEnqueueOrRefreshAsync(item, ct);
  77. _logger.LogInformation(
  78. "[PICK_NBR_SYNC] enqueue tenant={Tenant} wo={Wo} domain={Domain} refreshed={Ok}",
  79. tenantId, workOrd, mesDomain, ok);
  80. return true;
  81. }
  82. public async Task<MdpPushResult> ApplyFromOutboxAsync(MdpOutbox item, CancellationToken ct)
  83. {
  84. PickNbrSyncPayload payload;
  85. try
  86. {
  87. payload = JsonSerializer.Deserialize<PickNbrSyncPayload>(item.PayloadJson ?? "")
  88. ?? throw new InvalidOperationException("payload 为空");
  89. }
  90. catch (Exception ex)
  91. {
  92. return MdpPushResult.Fail($"PAYLOAD_INVALID: {ex.Message}");
  93. }
  94. var domain = ResolveMesDomain(payload.Domain);
  95. var workOrd = (payload.WorkOrd ?? "").Trim();
  96. var account = Trunc(payload.Account, 24);
  97. if (string.IsNullOrWhiteSpace(workOrd))
  98. return MdpPushResult.Fail("PAYLOAD_INVALID: workOrd 为空");
  99. var plan = await LoadPlanLinesAsync(item.TenantId, workOrd);
  100. ISqlSugarClient ss;
  101. try
  102. {
  103. ss = await _scopeFactory.GetScopeAsync(TargetSource, ct);
  104. }
  105. catch (Exception ex)
  106. {
  107. return MdpPushResult.Fail($"MES_UNREACHABLE: {ex.Message}");
  108. }
  109. try
  110. {
  111. var result = await ApplyOn165Async(ss, item.TenantId, domain, workOrd, account, plan, ct);
  112. return result;
  113. }
  114. catch (Exception ex)
  115. {
  116. _logger.LogWarning(ex, "[PICK_NBR_SYNC] apply failed wo={Wo} domain={Domain}", workOrd, domain);
  117. return MdpPushResult.Fail($"MES_WRITE_FAILED: {ex.Message}");
  118. }
  119. }
  120. private async Task<MdpPushResult> ApplyOn165Async(
  121. ISqlSugarClient ss,
  122. long tenantId,
  123. string domain,
  124. string workOrd,
  125. string account,
  126. List<PlanLine> plan,
  127. CancellationToken ct)
  128. {
  129. var sm = (await ss.Ado.SqlQueryAsync<SmHead>(
  130. """
  131. SELECT TOP 1 RecID, Nbr, Domain
  132. FROM NbrMaster
  133. WHERE Domain = @d AND WorkOrd = @w AND Type = 'SM'
  134. AND ISNULL(TransType, '') = ''
  135. AND ISNULL(IsActive, 1) = 1
  136. ORDER BY RecID DESC
  137. """,
  138. new SugarParameter("@d", domain),
  139. new SugarParameter("@w", workOrd))).FirstOrDefault();
  140. if (sm == null || string.IsNullOrWhiteSpace(sm.Nbr))
  141. {
  142. return MdpPushResult.Skip(JsonSerializer.Serialize(new
  143. {
  144. idempotent = true,
  145. reason = "no_sm",
  146. workOrd,
  147. domain
  148. }));
  149. }
  150. var existing = await ss.Ado.SqlQueryAsync<SmLine>(
  151. """
  152. SELECT RecID, ItemNum, QtyOrd, QtyRec, CurrQtyOpened, Line, Status
  153. FROM NbrDetail
  154. WHERE NbrRecID = @rid AND Type = 'SM'
  155. AND ISNULL(IsActive, 1) = 1
  156. """,
  157. new SugarParameter("@rid", sm.RecID));
  158. var planMap = plan
  159. .Where(p => !string.IsNullOrWhiteSpace(p.ItemNum))
  160. .ToDictionary(p => p.ItemNum.Trim(), p => p, StringComparer.OrdinalIgnoreCase);
  161. var existingMap = existing
  162. .ToDictionary(x => (x.ItemNum ?? "").Trim(), x => x, StringComparer.OrdinalIgnoreCase);
  163. var now = DateTime.Now;
  164. var updated = 0;
  165. var closed = 0;
  166. var inserted = 0;
  167. await ss.Ado.BeginTranAsync();
  168. try
  169. {
  170. foreach (var line in existing)
  171. {
  172. var key = (line.ItemNum ?? "").Trim();
  173. var still = planMap.TryGetValue(key, out var p);
  174. var newQty = still ? p!.QtyRequired : 0m;
  175. var action = PickBillNbrSyncRules.DecideExisting(line.QtyRec, newQty, still);
  176. if (action == PickBillNbrSyncRules.LineAction.Close)
  177. {
  178. await ss.Ado.ExecuteCommandAsync(
  179. """
  180. UPDATE NbrDetail
  181. SET Status = 'C',
  182. UpdateUser = @u,
  183. UpdateTime = @t
  184. WHERE RecID = @id
  185. """,
  186. new SugarParameter("@u", account),
  187. new SugarParameter("@t", now),
  188. new SugarParameter("@id", line.RecID));
  189. closed++;
  190. continue;
  191. }
  192. if (action == PickBillNbrSyncRules.LineAction.UpdateQty
  193. && (line.QtyOrd != newQty || !string.Equals(line.Status ?? "", "", StringComparison.Ordinal)))
  194. {
  195. await ss.Ado.ExecuteCommandAsync(
  196. """
  197. UPDATE NbrDetail
  198. SET QtyOrd = @q,
  199. CurrQtyOpened = @q,
  200. UM = @um,
  201. ItemName = @name,
  202. Status = '',
  203. UpdateUser = @u,
  204. UpdateTime = @t
  205. WHERE RecID = @id
  206. """,
  207. new SugarParameter("@q", newQty),
  208. new SugarParameter("@um", Trunc(p!.Unit, 8)),
  209. new SugarParameter("@name", Trunc(p.ItemName, 1000)),
  210. new SugarParameter("@u", account),
  211. new SugarParameter("@t", now),
  212. new SugarParameter("@id", line.RecID));
  213. updated++;
  214. }
  215. }
  216. short nextLine = existing.Count > 0 ? (short)(existing.Max(x => x.Line) + 1) : (short)1;
  217. foreach (var p in plan)
  218. {
  219. var key = p.ItemNum.Trim();
  220. if (existingMap.ContainsKey(key))
  221. continue;
  222. await ss.Ado.ExecuteCommandAsync(
  223. """
  224. INSERT INTO NbrDetail
  225. (Domain, Type, Nbr, Line, ItemNum, Dimension1, Dimension2,
  226. LocationFrom, LocationTo, QtyFrom, QtyTo, UM, [Print], Status, LotSerial,
  227. WorkOrd, QtyOrd, QtyRec, Address, BusinessID, CreateUser, UpdateUser,
  228. CreateTime, UpdateTime, IsActive, IsConfirm, QtyCache, CurrQtyOpened,
  229. IsChanged, NbrRecID, OrdNbr, ItemName, ERPfld1, ERPfld2, OrdLine,
  230. IsGP12Demand, IsGP12Checked, Material, SeqID)
  231. VALUES
  232. (@Domain, 'SM', @Nbr, @Line, @ItemNum, '', '',
  233. @LocationFrom, @LocationTo, 0, 0, @UM, 0, '', @LotSerial,
  234. @WorkOrd, @QtyOrd, 0, '', 0, @User, @User,
  235. @Now, @Now, 1, 0, 0, @QtyOrd,
  236. 1, @NbrRecID, '', @ItemName, '', '', 0,
  237. 0, 0, 0, 0)
  238. """,
  239. new SugarParameter("@Domain", domain),
  240. new SugarParameter("@Nbr", sm.Nbr),
  241. new SugarParameter("@Line", nextLine++),
  242. new SugarParameter("@ItemNum", Trunc(p.ItemNum, 24)),
  243. new SugarParameter("@LocationFrom", Trunc(p.LocationFrom, 8)),
  244. new SugarParameter("@LocationTo", Trunc(p.LocationTo, 8)),
  245. new SugarParameter("@UM", Trunc(p.Unit, 8)),
  246. new SugarParameter("@LotSerial", p.LotSerial ?? ""),
  247. new SugarParameter("@WorkOrd", workOrd),
  248. new SugarParameter("@QtyOrd", p.QtyRequired),
  249. new SugarParameter("@User", account),
  250. new SugarParameter("@Now", now),
  251. new SugarParameter("@NbrRecID", sm.RecID),
  252. new SugarParameter("@ItemName", Trunc(p.ItemName, 1000)));
  253. inserted++;
  254. }
  255. await ss.Ado.ExecuteCommandAsync(
  256. """
  257. UPDATE NbrMaster
  258. SET QtyOrd = (
  259. SELECT ISNULL(SUM(QtyOrd), 0)
  260. FROM NbrDetail
  261. WHERE NbrRecID = @rid AND Type = 'SM'
  262. AND ISNULL(IsActive, 1) = 1
  263. AND ISNULL(Status, '') <> 'C'
  264. ),
  265. UpdateUser = @u,
  266. UpdateTime = @t
  267. WHERE RecID = @rid
  268. """,
  269. new SugarParameter("@rid", sm.RecID),
  270. new SugarParameter("@u", account),
  271. new SugarParameter("@t", now));
  272. await ss.Ado.CommitTranAsync();
  273. }
  274. catch
  275. {
  276. await ss.Ado.RollbackTranAsync();
  277. throw;
  278. }
  279. try
  280. {
  281. await _hotWatch.EnrollPickBillAsync(domain, sm.Nbr!, workOrd, tenantId, ct);
  282. }
  283. catch (Exception ex)
  284. {
  285. _logger.LogWarning(ex, "[PICK_NBR_SYNC] hot-watch enroll failed nbr={Nbr} wo={Wo}", sm.Nbr, workOrd);
  286. }
  287. var response = JsonSerializer.Serialize(new
  288. {
  289. nbr = sm.Nbr,
  290. workOrd,
  291. domain,
  292. updated,
  293. closed,
  294. inserted
  295. });
  296. _logger.LogInformation(
  297. "[PICK_NBR_SYNC] applied nbr={Nbr} wo={Wo} upd={U} close={C} ins={I}",
  298. sm.Nbr, workOrd, updated, closed, inserted);
  299. return MdpPushResult.Ok(updated + closed + inserted, response);
  300. }
  301. private async Task<List<PlanLine>> LoadPlanLinesAsync(long tenantId, string workOrd)
  302. {
  303. return await _db.Ado.SqlQueryAsync<PlanLine>(
  304. """
  305. SELECT
  306. TRIM(d.ItemNum) AS ItemNum,
  307. SUM(d.QtyRequired) AS QtyRequired,
  308. MAX(IFNULL(d.UM, im.Um)) AS Unit,
  309. MAX(im.Descr) AS ItemName,
  310. MAX(IFNULL(NULLIF(TRIM(d.Location), ''), IFNULL(im.Location, ''))) AS LocationFrom,
  311. MAX(IFNULL(m.Location, '')) AS LocationTo,
  312. MAX(IFNULL(d.LotSerial, '')) AS LotSerial
  313. FROM WorkOrdDetail d
  314. LEFT JOIN WorkOrdMaster m
  315. ON m.WorkOrd = d.WorkOrd AND m.tenant_id = d.tenant_id
  316. LEFT JOIN ItemMaster im
  317. ON im.ItemNum = d.ItemNum AND im.tenant_id = d.tenant_id
  318. WHERE d.tenant_id = @TenantId AND d.WorkOrd = @WorkOrd AND IFNULL(d.IsActive, 0) = 1
  319. GROUP BY TRIM(d.ItemNum)
  320. HAVING SUM(d.QtyRequired) > 0
  321. ORDER BY TRIM(d.ItemNum)
  322. """,
  323. new SugarParameter("@TenantId", tenantId),
  324. new SugarParameter("@WorkOrd", workOrd));
  325. }
  326. /// <summary>165 Domain 最长 8 位;本库若把 tenant_id 写进 Domain 则回落到 8010。</summary>
  327. public static string ResolveMesDomain(string? domain)
  328. {
  329. var d = (domain ?? "").Trim();
  330. if (string.IsNullOrWhiteSpace(d) || d.Length > 8)
  331. return "8010";
  332. return d;
  333. }
  334. private static string Trunc(string? s, int max)
  335. {
  336. s ??= "";
  337. return s.Length <= max ? s : s[..max];
  338. }
  339. private sealed class PickNbrSyncPayload
  340. {
  341. public string Domain { get; set; } = "8010";
  342. public string WorkOrd { get; set; } = "";
  343. public string Account { get; set; } = "aidop";
  344. }
  345. private sealed class PlanLine
  346. {
  347. public string ItemNum { get; set; } = "";
  348. public decimal QtyRequired { get; set; }
  349. public string? Unit { get; set; }
  350. public string? ItemName { get; set; }
  351. public string? LocationFrom { get; set; }
  352. public string? LocationTo { get; set; }
  353. public string? LotSerial { get; set; }
  354. }
  355. private sealed class SmHead
  356. {
  357. public int RecID { get; set; }
  358. public string? Nbr { get; set; }
  359. public string? Domain { get; set; }
  360. }
  361. private sealed class SmLine
  362. {
  363. public int RecID { get; set; }
  364. public string? ItemNum { get; set; }
  365. public decimal QtyOrd { get; set; }
  366. public decimal QtyRec { get; set; }
  367. public decimal CurrQtyOpened { get; set; }
  368. public short Line { get; set; }
  369. public string? Status { get; set; }
  370. }
  371. }