CreatePickBillService.cs 50 KB

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