CreatePickBillService.cs 49 KB

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