CreatePickBillService.cs 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042
  1. using System.Net.Sockets;
  2. using System.Text.Json;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  4. using Admin.NET.Plugin.AiDOP.DataPlatform.Sequence;
  5. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  6. using Admin.NET.Plugin.AiDOP.WorkOrder;
  7. using Microsoft.Extensions.Logging;
  8. using SqlSugar;
  9. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
  10. /// <summary>
  11. /// WP4 / WP10 · 下达建领料单。
  12. /// S1:165 事务、幂等三分类、重放收敛、本库+Outbox 原子、业务幂等键、工序走 Outbox。
  13. /// S2:统一返回契约、错误码、D11-A fail-fast。
  14. /// </summary>
  15. [ApiDescriptionSettings(Order = 327, Description = "下达建领料单")]
  16. [Route("api/aidop/wms-pick")]
  17. [AllowAnonymous]
  18. [NonUnify]
  19. public class CreatePickBillService : IDynamicApiController, ITransient
  20. {
  21. public const string TargetSource = "DOPDEMORQ_SQLSERVER";
  22. private const string NbrTypeBill = "SM";
  23. private const string NbrTypeAllocate = "sm";
  24. private readonly ISqlSugarClient _db;
  25. private readonly MdpSourceScopeFactory _scopeFactory;
  26. private readonly INbrSequenceService _seq;
  27. private readonly KeeperResolveService _keeper;
  28. private readonly MdpOutboxEnqueueService _enqueue;
  29. private readonly MdpOutboxWakeSignal _wake;
  30. private readonly ILogger<CreatePickBillService> _logger;
  31. public CreatePickBillService(
  32. ISqlSugarClient db,
  33. MdpSourceScopeFactory scopeFactory,
  34. INbrSequenceService seq,
  35. KeeperResolveService keeper,
  36. MdpOutboxEnqueueService enqueue,
  37. MdpOutboxWakeSignal wake,
  38. ILogger<CreatePickBillService> logger)
  39. {
  40. _db = db;
  41. _scopeFactory = scopeFactory;
  42. _seq = seq;
  43. _keeper = keeper;
  44. _enqueue = enqueue;
  45. _wake = wake;
  46. _logger = logger;
  47. }
  48. public sealed class CreatePickBillInput
  49. {
  50. public string? WorkOrd { get; set; }
  51. public string Domain { get; set; } = "8010";
  52. public string UserAccount { get; set; } = "aidop";
  53. public long TenantId { get; set; }
  54. }
  55. public sealed class PickBillItemResult
  56. {
  57. public string WorkOrd { get; set; } = "";
  58. public string Result { get; set; } = ""; // created | existed | skipped | failed
  59. public string? Nbr { get; set; }
  60. public int? DetailCount { get; set; }
  61. public string? ReasonCode { get; set; }
  62. public string? Reason { get; set; }
  63. public string? Hint { get; set; }
  64. }
  65. public sealed class PickBillSummary
  66. {
  67. public int Requested { get; set; }
  68. public int Created { get; set; }
  69. public int Existed { get; set; }
  70. public int Skipped { get; set; }
  71. public int Failed { get; set; }
  72. }
  73. public sealed class PickBillWriteback
  74. {
  75. public int Enqueued { get; set; }
  76. public string State { get; set; } = "none";
  77. public bool? Reconciled { get; set; }
  78. }
  79. public sealed class PickBillResponse
  80. {
  81. public bool Ok { get; set; }
  82. public string Code { get; set; } = "FAILED";
  83. public PickBillSummary Summary { get; set; } = new();
  84. public List<PickBillItemResult> Items { get; set; } = new();
  85. public PickBillWriteback Writeback { get; set; } = new();
  86. public string? Trace { get; set; }
  87. public string? Message { get; set; }
  88. }
  89. [DisplayName("下达生成领料单")]
  90. [HttpPost("create")]
  91. public async Task<PickBillResponse> Create([FromBody] CreatePickBillInput input, CancellationToken ct = default)
  92. {
  93. if (input == null)
  94. return FailAll("body 不能为空", "MES_WRITE_FAILED");
  95. var domain = string.IsNullOrWhiteSpace(input.Domain) ? "8010" : input.Domain.Trim();
  96. var user = string.IsNullOrWhiteSpace(input.UserAccount) ? "aidop" : input.UserAccount.Trim();
  97. var trace = $"pick|{domain}|{DateTime.Now:yyyyMMddHHmmss}|{Guid.NewGuid():N}"[..48];
  98. var masters = await LoadCandidatesAsync(domain, input.WorkOrd, ct);
  99. if (masters.Count == 0)
  100. return BuildResponse(new List<PickBillItemResult>(), 0, 0, false, trace, "没有需要下达的工单。");
  101. var items = new List<PickBillItemResult>();
  102. var toCreate = new List<WorkOrdMaster>();
  103. var toReconcile = new List<(WorkOrdMaster Wo, string Nbr)>();
  104. int enqueued = 0;
  105. bool reconciled = false;
  106. ISqlSugarClient ss;
  107. try
  108. {
  109. ss = await _scopeFactory.GetScopeAsync(TargetSource, ct);
  110. }
  111. catch (Exception ex)
  112. {
  113. var code = ClassifyMesError(ex);
  114. return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace);
  115. }
  116. Dictionary<string, (string Nbr, bool HasDetail)> probe;
  117. try
  118. {
  119. probe = await ProbeExistingAsync(ss, domain, masters.Select(m => m.WorkOrd!).Distinct().ToList(), ct);
  120. }
  121. catch (Exception ex)
  122. {
  123. var code = ClassifyMesError(ex);
  124. return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace);
  125. }
  126. var workOrds = masters.Select(m => m.WorkOrd!).Distinct().ToList();
  127. var details = await _db.Queryable<WorkOrdDetail>()
  128. .Where(d => d.IsActive && workOrds.Contains(d.WorkOrd!))
  129. .ToListAsync(ct);
  130. var detailsByWo = details.GroupBy(d => d.WorkOrd!, StringComparer.OrdinalIgnoreCase)
  131. .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
  132. var itemNums = details.Select(d => d.ItemNum!).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
  133. var itemMap = await LoadItemsAsync(itemNums, ct);
  134. KeeperResolveService.KeeperResolveResult? keeperResult = null;
  135. if (itemNums.Count > 0)
  136. {
  137. try
  138. {
  139. keeperResult = await _keeper.ResolveByItemsAsync(domain, itemNums, ct);
  140. }
  141. catch (Exception ex)
  142. {
  143. var code = ClassifyMesError(ex);
  144. return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace);
  145. }
  146. }
  147. else
  148. {
  149. keeperResult = new KeeperResolveService.KeeperResolveResult();
  150. }
  151. var keeperByItem = keeperResult.Hits
  152. .GroupBy(h => h.ItemNum, StringComparer.OrdinalIgnoreCase)
  153. .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
  154. // 按工单分类(保管员硬校验在写库前对「待建」工单整体执行)
  155. foreach (var wo in masters)
  156. {
  157. var woNo = wo.WorkOrd ?? "";
  158. if (string.IsNullOrWhiteSpace(woNo)) continue;
  159. var st = (wo.Status ?? "").ToLowerInvariant();
  160. if (st is "c" or "w")
  161. {
  162. items.Add(new PickBillItemResult
  163. {
  164. WorkOrd = woNo, Result = "skipped", ReasonCode = "WO_STATE_INVALID",
  165. Reason = "工单状态不允许下达", Hint = "检查工单状态后重试"
  166. });
  167. continue;
  168. }
  169. if (!detailsByWo.TryGetValue(woNo, out var wods) || wods.Count == 0)
  170. {
  171. items.Add(new PickBillItemResult
  172. {
  173. WorkOrd = woNo, Result = "skipped", ReasonCode = "NO_DETAIL",
  174. Reason = "工单无物料明细", Hint = "请先在工单明细中维护物料后重试"
  175. });
  176. continue;
  177. }
  178. if (probe.TryGetValue(woNo, out var p) && p.HasDetail)
  179. {
  180. toReconcile.Add((wo, p.Nbr));
  181. continue;
  182. }
  183. // 孤儿:有主无明细 → 允许重建
  184. toCreate.Add(wo);
  185. }
  186. // 保管员校验:对待建工单涉及的物料
  187. if (toCreate.Count > 0)
  188. {
  189. var createItemNums = toCreate
  190. .SelectMany(w => detailsByWo.TryGetValue(w.WorkOrd!, out var ds) ? ds : new List<WorkOrdDetail>())
  191. .Select(d => d.ItemNum!)
  192. .Where(x => !string.IsNullOrWhiteSpace(x))
  193. .Distinct(StringComparer.OrdinalIgnoreCase)
  194. .ToList();
  195. var createKeeper = createItemNums.Count == 0
  196. ? new KeeperResolveService.KeeperResolveResult()
  197. : await FilterKeeperForItems(keeperResult, createItemNums);
  198. if (createKeeper.UnsafeItems.Count > 0)
  199. {
  200. foreach (var wo in toCreate)
  201. {
  202. items.Add(new PickBillItemResult
  203. {
  204. WorkOrd = wo.WorkOrd!, Result = "failed", ReasonCode = "KEEPER_RULE_UNSAFE",
  205. Reason = "物料号或职责区间含约定外字符,区间匹配口径不可靠",
  206. Hint = "请人工指派保管员;见 WP7 §3.6"
  207. });
  208. }
  209. toCreate.Clear();
  210. }
  211. else if (createKeeper.EmptyLocationItems.Count > 0)
  212. {
  213. foreach (var wo in toCreate)
  214. {
  215. items.Add(new PickBillItemResult
  216. {
  217. WorkOrd = wo.WorkOrd!, Result = "failed", ReasonCode = "NO_KEEPER",
  218. Reason = "物料保管员的库位不能为空:" + string.Join(",", createKeeper.EmptyLocationItems),
  219. Hint = "见 WP7 §3,补齐 EmpWorkDutyMaster.Location"
  220. });
  221. }
  222. toCreate.Clear();
  223. }
  224. else if (createKeeper.MissingItems.Count > 0)
  225. {
  226. foreach (var wo in toCreate)
  227. {
  228. items.Add(new PickBillItemResult
  229. {
  230. WorkOrd = wo.WorkOrd!, Result = "failed", ReasonCode = "NO_KEEPER",
  231. Reason = "以下物料未设置保管员:" + string.Join(",", createKeeper.MissingItems),
  232. Hint = "见 WP7 §3,补齐 EmpWorkDutyMaster"
  233. });
  234. }
  235. toCreate.Clear();
  236. }
  237. }
  238. var draftMasters = new List<DraftNbrMaster>();
  239. var draftDetails = new List<DraftNbrDetail>();
  240. var orphanNotes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  241. foreach (var wo in toCreate)
  242. {
  243. var wods = detailsByWo[wo.WorkOrd!];
  244. var invalidItems = wods.Where(d => !string.IsNullOrWhiteSpace(d.ItemNum) && !itemMap.ContainsKey(d.ItemNum!))
  245. .Select(d => d.ItemNum!).Distinct().ToList();
  246. if (invalidItems.Count > 0 && wods.All(d => string.IsNullOrWhiteSpace(d.ItemNum) || !itemMap.ContainsKey(d.ItemNum!)))
  247. {
  248. items.Add(new PickBillItemResult
  249. {
  250. WorkOrd = wo.WorkOrd!, Result = "skipped", ReasonCode = "ITEM_INVALID",
  251. Reason = "物料主数据缺失", Hint = "补 ItemMaster"
  252. });
  253. continue;
  254. }
  255. var before = draftMasters.Count;
  256. BuildOne(wo, wods, itemMap, keeperByItem, user, draftMasters, draftDetails);
  257. if (draftMasters.Count == before)
  258. {
  259. items.Add(new PickBillItemResult
  260. {
  261. WorkOrd = wo.WorkOrd!, Result = "skipped", ReasonCode = "ITEM_INVALID",
  262. Reason = "未生成有效明细", Hint = "检查工单明细与物料主数据"
  263. });
  264. continue;
  265. }
  266. if (probe.TryGetValue(wo.WorkOrd!, out var orphan) && !orphan.HasDetail)
  267. orphanNotes[wo.WorkOrd!] = orphan.Nbr;
  268. }
  269. if (draftMasters.Count > 0)
  270. {
  271. try
  272. {
  273. var nbrs = await _seq.AllocateAsync(NbrTypeAllocate, draftMasters.Count, domain, user, ct);
  274. if (nbrs.Count < draftMasters.Count)
  275. {
  276. foreach (var m in draftMasters)
  277. {
  278. items.Add(new PickBillItemResult
  279. {
  280. WorkOrd = m.WorkOrd, Result = "failed", ReasonCode = "SEQ_FAILED",
  281. Reason = $"取号不足:需要 {draftMasters.Count},得到 {nbrs.Count}",
  282. Hint = "联系运维查 NbrDayInfo"
  283. });
  284. }
  285. draftMasters.Clear();
  286. draftDetails.Clear();
  287. }
  288. else
  289. {
  290. for (var i = 0; i < draftMasters.Count; i++)
  291. {
  292. var nbr = nbrs[i];
  293. draftMasters[i].Nbr = nbr;
  294. var woTail = Tail(draftMasters[i].WorkOrd);
  295. draftMasters[i].Address = woTail;
  296. draftMasters[i].Ufld1 = woTail;
  297. foreach (var d in draftDetails.Where(x => x.TempKey == draftMasters[i].TempKey))
  298. {
  299. d.Nbr = nbr;
  300. d.Address = woTail;
  301. d.OrdNbr = woTail;
  302. d.ERPfld1 = woTail;
  303. d.OrdLine = d.Line;
  304. d.ERPfld2 = d.Line.ToString();
  305. }
  306. }
  307. await InsertNbrOn165Async(ss, draftMasters, draftDetails, ct);
  308. foreach (var m in draftMasters)
  309. {
  310. var detailCount = draftDetails.Count(d => d.Nbr == m.Nbr);
  311. if (orphanNotes.TryGetValue(m.WorkOrd, out var orphanNbr))
  312. {
  313. items.Add(new PickBillItemResult
  314. {
  315. WorkOrd = m.WorkOrd, Result = "created", Nbr = m.Nbr, DetailCount = detailCount,
  316. ReasonCode = "ORPHAN_DETECTED",
  317. Reason = $"检出孤儿单 {orphanNbr},已用新号重建",
  318. Hint = $"孤儿单号 {orphanNbr} 仍保留,交 DBA 决定是否清理"
  319. });
  320. }
  321. else
  322. {
  323. items.Add(new PickBillItemResult
  324. {
  325. WorkOrd = m.WorkOrd, Result = "created", Nbr = m.Nbr, DetailCount = detailCount
  326. });
  327. }
  328. }
  329. }
  330. }
  331. catch (Exception ex)
  332. {
  333. var code = ClassifyMesError(ex);
  334. // D11-A:MES 不可达整批 fail-fast,不开启本库事务
  335. if (code == "MES_UNREACHABLE")
  336. return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace);
  337. foreach (var m in draftMasters)
  338. {
  339. items.Add(new PickBillItemResult
  340. {
  341. WorkOrd = m.WorkOrd, Result = "failed", ReasonCode = code,
  342. Reason = DescribeMes(code, ex), Hint = HintFor(code)
  343. });
  344. }
  345. draftMasters.Clear();
  346. }
  347. }
  348. // 本库状态 + Outbox(新建 + 重放补做)
  349. var localTargets = new List<WorkOrdMaster>();
  350. localTargets.AddRange(draftMasters.Select(m => masters.First(x =>
  351. string.Equals(x.WorkOrd, m.WorkOrd, StringComparison.OrdinalIgnoreCase))));
  352. localTargets.AddRange(toReconcile.Select(x => x.Wo));
  353. localTargets = localTargets
  354. .GroupBy(x => x.WorkOrd!, StringComparer.OrdinalIgnoreCase)
  355. .Select(g => g.First())
  356. .ToList();
  357. if (localTargets.Count > 0)
  358. {
  359. var pendingPulse = false;
  360. try
  361. {
  362. await _db.Ado.BeginTranAsync();
  363. try
  364. {
  365. var now = DateTime.Now;
  366. var touchedOrds = localTargets.Select(x => x.WorkOrd!).ToList();
  367. foreach (var wo in localTargets)
  368. {
  369. var batch = Tail(wo.WorkOrd!);
  370. await _db.Updateable<WorkOrdMaster>()
  371. .SetColumns(m => new WorkOrdMaster
  372. {
  373. Status = "r",
  374. Batch = batch,
  375. UpdateUser = user,
  376. UpdateTime = now
  377. })
  378. .Where(m => m.WorkOrd == wo.WorkOrd)
  379. .ExecuteCommandAsync(ct);
  380. }
  381. await _db.Updateable<WorkOrdRouting>()
  382. .SetColumns(r => new WorkOrdRouting { Status = "r", UpdateUser = user, UpdateTime = now })
  383. .Where(r => touchedOrds.Contains(r.WorkOrd!) && r.IsActive
  384. && r.Status != null && r.Status.ToLower() != "c" && r.Status.ToUpper() != "W")
  385. .ExecuteCommandAsync(ct);
  386. foreach (var wo in localTargets)
  387. {
  388. var batch = Tail(wo.WorkOrd!);
  389. var insert = BuildWorkOrdInsert(domain, wo.WorkOrd!, batch, user, now, wo);
  390. var update = new Dictionary<string, object?>
  391. {
  392. ["Status"] = "r",
  393. ["Batch"] = batch,
  394. ["UpdateUser"] = Trunc(user, 24),
  395. ["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss")
  396. };
  397. if (await EnqueueAsync(input.TenantId, $"pick|{domain}|{wo.WorkOrd}|wom", "PICK_WOM_UPSERT", new
  398. {
  399. op = "UPSERT",
  400. table = "WorkOrdMaster",
  401. keys = new Dictionary<string, object?> { ["Domain"] = domain, ["WorkOrd"] = wo.WorkOrd },
  402. update,
  403. expect = new Dictionary<string, object?>(),
  404. insert
  405. }, ct, pulse: false))
  406. enqueued++;
  407. // 165 里程碑工序:先查 OP,再逐条入队 UPDATE
  408. List<int> ops;
  409. try
  410. {
  411. ops = await ss.Ado.SqlQueryAsync<int>(
  412. @"SELECT OP FROM WorkOrdRouting
  413. WHERE Domain=@d AND WorkOrd=@w
  414. AND MilestoneOp=1
  415. AND ISNULL(Status,'') NOT IN ('C','c')",
  416. new SugarParameter("@d", domain),
  417. new SugarParameter("@w", wo.WorkOrd));
  418. }
  419. catch
  420. {
  421. ops = new List<int>();
  422. }
  423. foreach (var op in ops)
  424. {
  425. if (await EnqueueAsync(input.TenantId, $"pick|{domain}|{wo.WorkOrd}|{op}|wor", "PICK_WOR_STATUS", new
  426. {
  427. op = "UPDATE",
  428. table = "WorkOrdRouting",
  429. keys = new Dictionary<string, object?>
  430. {
  431. ["Domain"] = domain,
  432. ["WorkOrd"] = wo.WorkOrd,
  433. ["OP"] = op
  434. },
  435. update = new Dictionary<string, object?>
  436. {
  437. ["Status"] = "r",
  438. ["UpdateUser"] = Trunc(user, 24),
  439. ["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss")
  440. },
  441. expect = new Dictionary<string, object?>()
  442. }, ct, pulse: false))
  443. enqueued++;
  444. }
  445. }
  446. await _db.Ado.CommitTranAsync();
  447. pendingPulse = true;
  448. reconciled = toReconcile.Count > 0;
  449. }
  450. catch
  451. {
  452. await _db.Ado.RollbackTranAsync();
  453. throw;
  454. }
  455. if (pendingPulse) _wake.Pulse();
  456. }
  457. catch (Exception ex)
  458. {
  459. _logger.LogError(ex, "[CreatePickBill] 本库事务失败 trace={Trace}", trace);
  460. foreach (var wo in localTargets)
  461. {
  462. if (items.Any(i => i.WorkOrd == wo.WorkOrd && i.Result is "created" or "existed"))
  463. continue;
  464. items.Add(new PickBillItemResult
  465. {
  466. WorkOrd = wo.WorkOrd!, Result = "failed", ReasonCode = "MES_WRITE_FAILED",
  467. Reason = "本库状态更新失败:" + Trunc(ex.Message, 200),
  468. Hint = "查看服务日志"
  469. });
  470. }
  471. }
  472. }
  473. foreach (var (wo, nbr) in toReconcile)
  474. {
  475. if (items.Any(i => string.Equals(i.WorkOrd, wo.WorkOrd, StringComparison.OrdinalIgnoreCase)))
  476. continue;
  477. items.Add(new PickBillItemResult
  478. {
  479. WorkOrd = wo.WorkOrd!, Result = "existed", Nbr = nbr, ReasonCode = "EXISTS",
  480. Reason = "165 上已有有效领料单"
  481. });
  482. }
  483. _logger.LogInformation(
  484. "[CreatePickBill] domain={Domain} requested={Req} created={C} existed={E} outbox={Out} trace={Trace}",
  485. domain, masters.Count, items.Count(i => i.Result == "created"), items.Count(i => i.Result == "existed"),
  486. enqueued, trace);
  487. return BuildResponse(items, enqueued, reconciled ? 1 : 0, reconciled, trace);
  488. }
  489. private static Task<KeeperResolveService.KeeperResolveResult> FilterKeeperForItems(
  490. KeeperResolveService.KeeperResolveResult full, List<string> itemNums)
  491. {
  492. var set = new HashSet<string>(itemNums, StringComparer.OrdinalIgnoreCase);
  493. return Task.FromResult(new KeeperResolveService.KeeperResolveResult
  494. {
  495. Hits = full.Hits.Where(h => set.Contains(h.ItemNum)).ToList(),
  496. MissingItems = full.MissingItems.Where(x => set.Contains(x)).ToList(),
  497. EmptyLocationItems = full.EmptyLocationItems.Where(x => set.Contains(x)).ToList(),
  498. UnsafeItems = full.UnsafeItems.Where(x => set.Contains(x)).ToList()
  499. });
  500. }
  501. private async Task<Dictionary<string, (string Nbr, bool HasDetail)>> ProbeExistingAsync(
  502. ISqlSugarClient ss, string domain, List<string> workOrds, CancellationToken ct)
  503. {
  504. var result = new Dictionary<string, (string Nbr, bool HasDetail)>(StringComparer.OrdinalIgnoreCase);
  505. if (workOrds.Count == 0) return result;
  506. var inSql = string.Join(",", workOrds.Select((_, k) => "@w" + k));
  507. var pars = workOrds.Select((w, k) => new SugarParameter("@w" + k, w)).ToList();
  508. pars.Add(new SugarParameter("@d", domain));
  509. pars.Add(new SugarParameter("@t", NbrTypeBill));
  510. var rows = await ss.Ado.SqlQueryAsync<ProbeRow>($@"
  511. SELECT m.WorkOrd, m.Nbr,
  512. CASE WHEN EXISTS (SELECT 1 FROM NbrDetail d WHERE d.NbrRecID = m.RecID)
  513. THEN 1 ELSE 0 END AS HasDetail
  514. FROM NbrMaster m
  515. WHERE m.Domain = @d AND m.Type = @t
  516. AND ISNULL(m.IsActive, 1) = 1
  517. AND m.WorkOrd IN ({inSql})", pars.ToArray());
  518. foreach (var g in rows.GroupBy(r => r.WorkOrd ?? "", StringComparer.OrdinalIgnoreCase))
  519. {
  520. var best = g.OrderByDescending(x => x.HasDetail).ThenBy(x => x.Nbr).First();
  521. result[g.Key] = (best.Nbr ?? "", best.HasDetail != 0);
  522. }
  523. return result;
  524. }
  525. private async Task<Dictionary<string, ItemLite>> LoadItemsAsync(List<string> itemNums, CancellationToken ct)
  526. {
  527. var itemMap = new Dictionary<string, ItemLite>(StringComparer.OrdinalIgnoreCase);
  528. if (itemNums.Count == 0) return itemMap;
  529. var inSql = string.Join(",", itemNums.Select((_, k) => "@i" + k));
  530. var pars = itemNums.Select((n, k) => new SugarParameter("@i" + k, n)).ToArray();
  531. var items = await _db.Ado.SqlQueryAsync<ItemLite>(
  532. $"SELECT ItemNum, Descr, UM, Location FROM ItemMaster WHERE ItemNum IN ({inSql})", pars);
  533. foreach (var it in items)
  534. {
  535. if (!string.IsNullOrWhiteSpace(it.ItemNum) && !itemMap.ContainsKey(it.ItemNum))
  536. itemMap[it.ItemNum] = it;
  537. }
  538. return itemMap;
  539. }
  540. private async Task<List<WorkOrdMaster>> LoadCandidatesAsync(string domain, string? workOrdCsv, CancellationToken ct)
  541. {
  542. if (string.IsNullOrWhiteSpace(workOrdCsv))
  543. {
  544. var from = DateTime.Now.Date.AddDays(1);
  545. var to = DateTime.Now.Date.AddDays(4);
  546. return await _db.Queryable<WorkOrdMaster>()
  547. .Where(m => m.Domain == domain
  548. && m.OrdDate >= from && m.OrdDate < to
  549. && m.Status != null && m.Status.ToLower() == "p")
  550. .ToListAsync(ct);
  551. }
  552. var list = workOrdCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
  553. if (list.Length == 0) return new List<WorkOrdMaster>();
  554. return await _db.Queryable<WorkOrdMaster>()
  555. .Where(m => list.Contains(m.WorkOrd!)
  556. && m.Status != null
  557. && m.Status.ToLower() != "c"
  558. && m.Status.ToLower() != "w")
  559. .ToListAsync(ct);
  560. }
  561. private static Dictionary<string, object?> BuildWorkOrdInsert(
  562. string domain, string workOrd, string batch, string user, DateTime now, WorkOrdMaster? local)
  563. {
  564. var insert = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
  565. {
  566. ["Domain"] = domain,
  567. ["WorkOrd"] = workOrd,
  568. ["ID"] = workOrd,
  569. ["Status"] = "r",
  570. ["Batch"] = batch,
  571. ["ItemNum"] = local?.ItemNum ?? "",
  572. ["Site"] = local?.Site ?? "",
  573. ["Typed"] = local?.Typed ?? "",
  574. ["QtyOrded"] = local?.QtyOrded ?? 0m,
  575. ["ProdLine"] = local?.ProdLine ?? "",
  576. ["Location"] = local?.Location ?? "",
  577. ["Priority"] = local?.Priority ?? 0m,
  578. ["Urgent"] = local?.Urgent ?? 0,
  579. ["ItemName"] = local?.ItemName ?? "",
  580. ["Department"] = local?.Department ?? "",
  581. ["RoutingCode"] = local?.RoutingCode ?? "",
  582. ["BOMFormula"] = local?.BOMFormula ?? "",
  583. ["Project"] = local?.Project ?? "",
  584. ["Drawing"] = local?.Drawing ?? "",
  585. ["Rev"] = local?.Rev ?? "",
  586. ["CustNo"] = local?.CustNo ?? "",
  587. ["BusinessID"] = local?.BusinessID ?? 0L,
  588. ["IsActive"] = true,
  589. ["IsConfirm"] = local?.IsConfirm ?? false,
  590. ["CreateUser"] = Trunc(local?.CreateUser ?? user, 24),
  591. ["UpdateUser"] = Trunc(user, 24),
  592. ["CreateTime"] = (local?.CreateTime ?? now).ToString("yyyy-MM-dd HH:mm:ss"),
  593. ["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss")
  594. };
  595. if (local?.OrdDate != null)
  596. insert["OrdDate"] = local.OrdDate.Value.ToString("yyyy-MM-dd HH:mm:ss");
  597. if (local?.DueDate != null)
  598. insert["DueDate"] = local.DueDate.Value.ToString("yyyy-MM-dd HH:mm:ss");
  599. if (local?.ReleaseDate != null)
  600. insert["ReleaseDate"] = local.ReleaseDate.Value.ToString("yyyy-MM-dd HH:mm:ss");
  601. return insert;
  602. }
  603. private static void BuildOne(
  604. WorkOrdMaster wo,
  605. List<WorkOrdDetail> wods,
  606. Dictionary<string, ItemLite> itemMap,
  607. Dictionary<string, KeeperResolveService.KeeperHit> keeperByItem,
  608. string userAccount,
  609. List<DraftNbrMaster> masters,
  610. List<DraftNbrDetail> details)
  611. {
  612. var today = DateTime.Now.Date;
  613. var ord = (wo.OrdDate ?? today).Date;
  614. var eff = ord.AddDays(-1);
  615. if (eff < today) eff = today;
  616. var start = eff;
  617. var temp = Guid.NewGuid().ToString("N");
  618. var keepers = new List<string>();
  619. short line = 1;
  620. var agg = new Dictionary<string, DraftNbrDetail>(StringComparer.OrdinalIgnoreCase);
  621. foreach (var a in wods)
  622. {
  623. if (string.IsNullOrWhiteSpace(a.ItemNum)) continue;
  624. itemMap.TryGetValue(a.ItemNum, out var im);
  625. var locFrom = !string.IsNullOrWhiteSpace(a.Location)
  626. ? a.Location!
  627. : (im?.Location ?? "");
  628. if (keeperByItem.TryGetValue(a.ItemNum, out var kh) && !string.IsNullOrWhiteSpace(kh.Employee))
  629. keepers.Add(kh.Employee);
  630. if (agg.TryGetValue(a.ItemNum, out var exist))
  631. {
  632. exist.QtyOrd += a.QtyRequired;
  633. exist.CurrQtyOpened += a.QtyRequired;
  634. continue;
  635. }
  636. var d = new DraftNbrDetail
  637. {
  638. TempKey = temp,
  639. Domain = wo.Domain ?? "8010",
  640. Type = NbrTypeBill,
  641. ItemNum = a.ItemNum,
  642. QtyFrom = 0,
  643. QtyTo = 0,
  644. LocationFrom = locFrom,
  645. LocationTo = wo.Location ?? "",
  646. WorkOrd = wo.WorkOrd ?? "",
  647. QtyOrd = a.QtyRequired,
  648. CurrQtyOpened = a.QtyRequired,
  649. Line = line++,
  650. UM = im?.UM ?? "",
  651. LotSerial = a.LotSerial ?? "",
  652. ItemName = im?.Descr ?? "",
  653. Status = "",
  654. CreateUser = userAccount,
  655. UpdateUser = userAccount,
  656. CreateTime = DateTime.Now,
  657. UpdateTime = DateTime.Now
  658. };
  659. agg[a.ItemNum] = d;
  660. details.Add(d);
  661. }
  662. if (agg.Count == 0) return;
  663. var (user1, user2) = KeeperResolveService.FormatKeeperUsers(
  664. keepers.Distinct().Select(e => new KeeperResolveService.KeeperHit { Employee = e, ItemNum = "" }));
  665. masters.Add(new DraftNbrMaster
  666. {
  667. TempKey = temp,
  668. Domain = wo.Domain ?? "8010",
  669. Type = NbrTypeBill,
  670. Remark = "下达自动领料",
  671. ProdLine = wo.ProdLine ?? "",
  672. EffDate = eff,
  673. Date = start,
  674. Status = "",
  675. WorkOrd = wo.WorkOrd ?? "",
  676. QtyOrd = wo.QtyOrded,
  677. IsActive = true,
  678. IsChanged = true,
  679. Name = userAccount.Length > 12 ? userAccount[..12] : userAccount,
  680. Department = "101",
  681. CreateUser = userAccount,
  682. UpdateUser = userAccount,
  683. CreateTime = DateTime.Now,
  684. UpdateTime = DateTime.Now,
  685. TransType = "",
  686. User1 = user1,
  687. User2 = user2
  688. });
  689. }
  690. private static async Task InsertNbrOn165Async(
  691. ISqlSugarClient ss,
  692. List<DraftNbrMaster> masters,
  693. List<DraftNbrDetail> details,
  694. CancellationToken ct)
  695. {
  696. await ss.Ado.BeginTranAsync();
  697. try
  698. {
  699. foreach (var m in masters)
  700. {
  701. var recIdObj = await ss.Ado.SqlQuerySingleAsync<int?>(
  702. @"INSERT INTO NbrMaster
  703. (Domain, Type, Nbr, [Print], Status, Remark, Date, User1, User2,
  704. ProdLine, EffDate, WorkOrd, QtyOrd, QtyRec, Address, BusinessID,
  705. CreateUser, UpdateUser, CreateTime, UpdateTime, IsActive, IsConfirm,
  706. IsChanged, Department, Name, TransType, Ufld1, IsGP12Checked, IsPlan, IsReturn, Op)
  707. OUTPUT INSERTED.RecID
  708. VALUES
  709. (@Domain, @Type, @Nbr, 0, @Status, @Remark, @Date, @User1, @User2,
  710. @ProdLine, @EffDate, @WorkOrd, @QtyOrd, 0, @Address, 0,
  711. @CreateUser, @UpdateUser, @CreateTime, @UpdateTime, 1, 0,
  712. 1, @Department, @Name, @TransType, @Ufld1, 0, 0, 0, 0)",
  713. new SugarParameter("@Domain", m.Domain),
  714. new SugarParameter("@Type", m.Type),
  715. new SugarParameter("@Nbr", m.Nbr),
  716. new SugarParameter("@Status", m.Status ?? ""),
  717. new SugarParameter("@Remark", m.Remark ?? ""),
  718. new SugarParameter("@Date", m.Date),
  719. new SugarParameter("@User1", m.User1 ?? ""),
  720. new SugarParameter("@User2", m.User2 ?? "[]"),
  721. new SugarParameter("@ProdLine", m.ProdLine ?? ""),
  722. new SugarParameter("@EffDate", m.EffDate),
  723. new SugarParameter("@WorkOrd", m.WorkOrd),
  724. new SugarParameter("@QtyOrd", m.QtyOrd),
  725. new SugarParameter("@Address", m.Address ?? ""),
  726. new SugarParameter("@CreateUser", Trunc(m.CreateUser, 24)),
  727. new SugarParameter("@UpdateUser", Trunc(m.UpdateUser, 24)),
  728. new SugarParameter("@CreateTime", m.CreateTime),
  729. new SugarParameter("@UpdateTime", m.UpdateTime),
  730. new SugarParameter("@Department", m.Department ?? ""),
  731. new SugarParameter("@Name", m.Name ?? ""),
  732. new SugarParameter("@TransType", m.TransType ?? ""),
  733. new SugarParameter("@Ufld1", m.Ufld1 ?? ""));
  734. if (recIdObj is null or 0)
  735. throw new InvalidOperationException($"NbrMaster 插入未返回 RecID:{m.Nbr}");
  736. var recId = recIdObj.Value;
  737. foreach (var d in details.Where(x => x.TempKey == m.TempKey))
  738. {
  739. await ss.Ado.ExecuteCommandAsync(
  740. @"INSERT INTO NbrDetail
  741. (Domain, Type, Nbr, Line, ItemNum, Dimension1, Dimension2,
  742. LocationFrom, LocationTo, QtyFrom, QtyTo, UM, [Print], Status, LotSerial,
  743. WorkOrd, QtyOrd, QtyRec, Address, BusinessID, CreateUser, UpdateUser,
  744. CreateTime, UpdateTime, IsActive, IsConfirm, QtyCache, CurrQtyOpened,
  745. IsChanged, NbrRecID, OrdNbr, ItemName, ERPfld1, ERPfld2, OrdLine,
  746. IsGP12Demand, IsGP12Checked, Material, SeqID)
  747. VALUES
  748. (@Domain, @Type, @Nbr, @Line, @ItemNum, '', '',
  749. @LocationFrom, @LocationTo, 0, 0, @UM, 0, @Status, @LotSerial,
  750. @WorkOrd, @QtyOrd, 0, @Address, 0, @CreateUser, @UpdateUser,
  751. @CreateTime, @UpdateTime, 1, 0, 0, @CurrQtyOpened,
  752. 1, @NbrRecID, @OrdNbr, @ItemName, @ERPfld1, @ERPfld2, @OrdLine,
  753. 0, 0, 0, 0)",
  754. new SugarParameter("@Domain", d.Domain),
  755. new SugarParameter("@Type", d.Type),
  756. new SugarParameter("@Nbr", d.Nbr),
  757. new SugarParameter("@Line", d.Line),
  758. new SugarParameter("@ItemNum", d.ItemNum),
  759. new SugarParameter("@LocationFrom", Trunc(d.LocationFrom, 8)),
  760. new SugarParameter("@LocationTo", Trunc(d.LocationTo, 8)),
  761. new SugarParameter("@UM", Trunc(d.UM, 8)),
  762. new SugarParameter("@Status", d.Status ?? ""),
  763. new SugarParameter("@LotSerial", d.LotSerial ?? ""),
  764. new SugarParameter("@WorkOrd", d.WorkOrd),
  765. new SugarParameter("@QtyOrd", d.QtyOrd),
  766. new SugarParameter("@Address", Trunc(d.Address, 60)),
  767. new SugarParameter("@CreateUser", Trunc(d.CreateUser, 24)),
  768. new SugarParameter("@UpdateUser", Trunc(d.UpdateUser, 24)),
  769. new SugarParameter("@CreateTime", d.CreateTime),
  770. new SugarParameter("@UpdateTime", d.UpdateTime),
  771. new SugarParameter("@CurrQtyOpened", d.CurrQtyOpened),
  772. new SugarParameter("@NbrRecID", recId),
  773. new SugarParameter("@OrdNbr", Trunc(d.OrdNbr, 48)),
  774. new SugarParameter("@ItemName", Trunc(d.ItemName, 1000)),
  775. new SugarParameter("@ERPfld1", Trunc(d.ERPfld1, 200)),
  776. new SugarParameter("@ERPfld2", Trunc(d.ERPfld2, 200)),
  777. new SugarParameter("@OrdLine", d.OrdLine));
  778. }
  779. }
  780. await ss.Ado.CommitTranAsync();
  781. }
  782. catch
  783. {
  784. await ss.Ado.RollbackTranAsync();
  785. throw;
  786. }
  787. }
  788. private async Task<bool> EnqueueAsync(long tenantId, string idem, string action, object payload, CancellationToken ct, bool pulse = true)
  789. {
  790. var json = JsonSerializer.Serialize(payload);
  791. var item = new MdpOutbox
  792. {
  793. TenantId = tenantId,
  794. TargetSourceCode = TargetSource,
  795. ActionCode = action,
  796. IdemKey = idem.Length > 200 ? idem[..200] : idem,
  797. PayloadJson = json
  798. };
  799. return await _enqueue.TryEnqueueAsync(item, ct, pulse);
  800. }
  801. private static string ClassifyMesError(Exception ex)
  802. {
  803. for (var e = ex; e != null; e = e.InnerException!)
  804. {
  805. if (e is TimeoutException or SocketException) return "MES_UNREACHABLE";
  806. // SqlSugar / 驱动可能抛 Microsoft.Data.SqlClient.SqlException 或 System.Data.SqlClient.SqlException
  807. if (e.GetType().Name == "SqlException")
  808. {
  809. var numObj = e.GetType().GetProperty("Number")?.GetValue(e);
  810. if (numObj is int n && n is -2 or 53 or 4060 or 18456 or 10060 or 10061)
  811. return "MES_UNREACHABLE";
  812. return "MES_WRITE_FAILED";
  813. }
  814. var msg = e.Message ?? "";
  815. if (msg.Contains("timeout", StringComparison.OrdinalIgnoreCase)
  816. || msg.Contains("network", StringComparison.OrdinalIgnoreCase)
  817. || msg.Contains("连不上", StringComparison.OrdinalIgnoreCase)
  818. || msg.Contains("无法连接", StringComparison.OrdinalIgnoreCase))
  819. return "MES_UNREACHABLE";
  820. }
  821. return "MES_WRITE_FAILED";
  822. }
  823. private static string DescribeMes(string code, Exception ex) =>
  824. code == "MES_UNREACHABLE"
  825. ? "165 不可达:" + Trunc(ex.Message, 200)
  826. : "165 写入失败:" + Trunc(ex.Message, 200);
  827. private static string HintFor(string code) => code switch
  828. {
  829. "MES_UNREACHABLE" => "联系运维;稍后重试",
  830. "MES_WRITE_FAILED" => "附 SQL 错误信息,联系运维",
  831. "SEQ_FAILED" => "联系运维查 NbrDayInfo",
  832. _ => ""
  833. };
  834. private static PickBillResponse FailAll(string message, string code) => new()
  835. {
  836. Ok = false,
  837. Code = "FAILED",
  838. Message = message,
  839. Summary = new PickBillSummary { Requested = 0, Failed = 1 },
  840. Items =
  841. {
  842. new PickBillItemResult
  843. {
  844. WorkOrd = "", Result = "failed", ReasonCode = code, Reason = message, Hint = HintFor(code)
  845. }
  846. }
  847. };
  848. private static PickBillResponse FailAllBatch(
  849. List<WorkOrdMaster> masters, string code, string reason, string hint, string trace)
  850. {
  851. var items = masters.Select(m => new PickBillItemResult
  852. {
  853. WorkOrd = m.WorkOrd ?? "", Result = "failed", ReasonCode = code, Reason = reason, Hint = hint
  854. }).ToList();
  855. return BuildResponse(items, 0, 0, false, trace, reason);
  856. }
  857. private static PickBillResponse BuildResponse(
  858. List<PickBillItemResult> items, int enqueued, int _, bool reconciled, string? trace, string? message = null)
  859. {
  860. var summary = new PickBillSummary
  861. {
  862. Requested = items.Count,
  863. Created = items.Count(i => i.Result == "created"),
  864. Existed = items.Count(i => i.Result == "existed"),
  865. Skipped = items.Count(i => i.Result == "skipped"),
  866. Failed = items.Count(i => i.Result == "failed")
  867. };
  868. var code = "FAILED";
  869. if (summary.Failed == 0 && summary.Skipped == 0 && summary.Created > 0) code = "OK";
  870. else if (summary.Created > 0 && (summary.Failed > 0 || summary.Skipped > 0)) code = "PARTIAL";
  871. else if (summary.Created == 0 && summary.Failed == 0) code = "NOOP";
  872. else code = "FAILED";
  873. return new PickBillResponse
  874. {
  875. Ok = summary.Failed == 0 && summary.Created + summary.Existed > 0,
  876. Code = code,
  877. Summary = summary,
  878. Items = items,
  879. Writeback = new PickBillWriteback
  880. {
  881. Enqueued = enqueued,
  882. State = enqueued > 0 ? "pending" : "none",
  883. Reconciled = reconciled ? true : null
  884. },
  885. Trace = trace,
  886. Message = message
  887. };
  888. }
  889. private static string Tail(string workOrd) =>
  890. workOrd.Length > 1 ? workOrd[1..] : workOrd;
  891. private static string Trunc(string? s, int max) =>
  892. string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s[..max]);
  893. private sealed class ProbeRow
  894. {
  895. public string? WorkOrd { get; set; }
  896. public string? Nbr { get; set; }
  897. public int HasDetail { get; set; }
  898. }
  899. private sealed class ItemLite
  900. {
  901. public string ItemNum { get; set; } = "";
  902. public string? Descr { get; set; }
  903. public string? UM { get; set; }
  904. public string? Location { get; set; }
  905. }
  906. private sealed class DraftNbrMaster
  907. {
  908. public string TempKey { get; set; } = "";
  909. public string Domain { get; set; } = "";
  910. public string Type { get; set; } = "";
  911. public string Nbr { get; set; } = "";
  912. public string? Remark { get; set; }
  913. public string? ProdLine { get; set; }
  914. public DateTime EffDate { get; set; }
  915. public DateTime Date { get; set; }
  916. public string? Status { get; set; }
  917. public string WorkOrd { get; set; } = "";
  918. public decimal QtyOrd { get; set; }
  919. public bool IsActive { get; set; }
  920. public bool IsChanged { get; set; }
  921. public string? Name { get; set; }
  922. public string? Department { get; set; }
  923. public string? CreateUser { get; set; }
  924. public string? UpdateUser { get; set; }
  925. public DateTime CreateTime { get; set; }
  926. public DateTime UpdateTime { get; set; }
  927. public string? TransType { get; set; }
  928. public string? User1 { get; set; }
  929. public string? User2 { get; set; }
  930. public string? Address { get; set; }
  931. public string? Ufld1 { get; set; }
  932. }
  933. private sealed class DraftNbrDetail
  934. {
  935. public string TempKey { get; set; } = "";
  936. public string Domain { get; set; } = "";
  937. public string Type { get; set; } = "";
  938. public string Nbr { get; set; } = "";
  939. public string ItemNum { get; set; } = "";
  940. public decimal QtyFrom { get; set; }
  941. public decimal QtyTo { get; set; }
  942. public string LocationFrom { get; set; } = "";
  943. public string LocationTo { get; set; } = "";
  944. public string WorkOrd { get; set; } = "";
  945. public decimal QtyOrd { get; set; }
  946. public decimal CurrQtyOpened { get; set; }
  947. public short Line { get; set; }
  948. public string UM { get; set; } = "";
  949. public string LotSerial { get; set; } = "";
  950. public string ItemName { get; set; } = "";
  951. public string Status { get; set; } = "";
  952. public string? CreateUser { get; set; }
  953. public string? UpdateUser { get; set; }
  954. public DateTime CreateTime { get; set; }
  955. public DateTime UpdateTime { get; set; }
  956. public string? Address { get; set; }
  957. public string? OrdNbr { get; set; }
  958. public string? ERPfld1 { get; set; }
  959. public string? ERPfld2 { get; set; }
  960. public short OrdLine { get; set; }
  961. }
  962. }