CreatePickBillService.cs 48 KB

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