WorkOrderDispatchService.cs 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. using System.Globalization;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Microsoft.Extensions.Logging;
  6. using Microsoft.Extensions.Options;
  7. namespace Admin.NET.Plugin.AiDOP.WorkOrder;
  8. /// <summary>
  9. /// 工单下达服务 🏭
  10. /// 路由前缀:/api/WorkOrder/dispatch/...
  11. /// </summary>
  12. [ApiDescriptionSettings(Order = 260, Description = "工单下达")]
  13. [Route("api/WorkOrder")]
  14. [AllowAnonymous]
  15. [NonUnify]
  16. public class WorkOrderDispatchService : IDynamicApiController, ITransient
  17. {
  18. private readonly ISqlSugarClient _db;
  19. private readonly UserManager _userManager;
  20. private readonly WorkOrderKittingCheckService _kittingCheck;
  21. private readonly IPickBillCreator _pickBillCreator;
  22. private readonly AidopPickBillOptions _pickBillOpt;
  23. private readonly MdpSourceScopeFactory _scopeFactory;
  24. private readonly KeeperResolveService _keeper;
  25. private readonly WorkOrdMesPushService _workOrdMesPush;
  26. private readonly AidopActionRunLogWriter _runLog;
  27. private readonly ILogger<WorkOrderDispatchService> _logger;
  28. /// <summary>单次下达请求内的诊断上下文(写入 run_log.detail_json,不回传前端)。</summary>
  29. private ReleaseDiagContext? _diag;
  30. public WorkOrderDispatchService(
  31. ISqlSugarClient db,
  32. UserManager userManager,
  33. WorkOrderKittingCheckService kittingCheck,
  34. IPickBillCreator pickBillCreator,
  35. IOptions<AidopPickBillOptions> pickBillOpt,
  36. MdpSourceScopeFactory scopeFactory,
  37. KeeperResolveService keeper,
  38. WorkOrdMesPushService workOrdMesPush,
  39. AidopActionRunLogWriter runLog,
  40. ILogger<WorkOrderDispatchService> logger)
  41. {
  42. _db = db;
  43. _userManager = userManager;
  44. _kittingCheck = kittingCheck;
  45. _pickBillCreator = pickBillCreator;
  46. _pickBillOpt = pickBillOpt.Value;
  47. _scopeFactory = scopeFactory;
  48. _keeper = keeper;
  49. _workOrdMesPush = workOrdMesPush;
  50. _runLog = runLog;
  51. _logger = logger;
  52. }
  53. private long ResolveTenantId(long? requestTenantId) =>
  54. AidopTenantHelper.Resolve(App.HttpContext, requestTenantId);
  55. /// <summary>校验:本租户下存在处于初始状态(p)的该工单。</summary>
  56. private async Task AssertInitialWorkOrderExistsAsync(long tenantId, string workOrd)
  57. {
  58. var wo = workOrd.Trim();
  59. var cnt = await _db.Ado.GetIntAsync(
  60. """
  61. SELECT COUNT(*) FROM WorkOrdMaster
  62. WHERE tenant_id = @TenantId
  63. AND WorkOrd = @WorkOrd
  64. AND LOWER(TRIM(IFNULL(Status,''))) = 'p'
  65. """,
  66. new SugarParameter("@TenantId", tenantId),
  67. new SugarParameter("@WorkOrd", wo));
  68. if (cnt == 0)
  69. throw Oops.Oh("未找到处于初始状态(p)的本租户工单,请确认工单号与租户是否正确");
  70. }
  71. // ══════════════════════════════════════════════════════════════
  72. // 列表 GET /api/WorkOrder/dispatch/list
  73. // ══════════════════════════════════════════════════════════════
  74. /// <summary>获取工单下达分页列表 🏭</summary>
  75. [DisplayName("获取工单下达列表")]
  76. [HttpGet("dispatch/list")]
  77. public async Task<object> GetDispatchList([FromQuery] WorkOrderDispatchListInput input)
  78. {
  79. var tid = AidopTenantHelper.Resolve(App.HttpContext);
  80. var pars = new List<SugarParameter> { new SugarParameter("@TenantId", tid) };
  81. // 与库内其它原生 SQL 一致:统一 COLLATE,避免 utf8mb4_general_ci / utf8mb4_0900_ai_ci 混用报错
  82. const string C = "utf8mb4_general_ci";
  83. var conditions = new List<string> { $"a.tenant_id = @TenantId" };
  84. // 状态:可多选筛选。历史上此处写死 'p',导致已下达工单无法查询,行级「单工单齐套」「MES补偿」对其不可达。
  85. var statuses = (input.Status ?? string.Empty)
  86. .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  87. .Select(x => x.ToLowerInvariant())
  88. .Where(x => x is "p" or "r" or "w" or "c")
  89. .Distinct()
  90. .ToList();
  91. if (statuses.Count > 0)
  92. {
  93. var names = new List<string>();
  94. for (var i = 0; i < statuses.Count; i++)
  95. {
  96. names.Add($"@Status{i}");
  97. pars.Add(new SugarParameter($"@Status{i}", statuses[i]));
  98. }
  99. conditions.Add($"LOWER(TRIM(IFNULL(a.Status,''))) COLLATE {C} IN ({string.Join(", ", names)})");
  100. }
  101. if (!string.IsNullOrWhiteSpace(input.WorkOrd))
  102. {
  103. conditions.Add($"(a.WorkOrd COLLATE {C}) LIKE (@WorkOrd COLLATE {C})");
  104. pars.Add(new SugarParameter("@WorkOrd", $"%{input.WorkOrd.Trim()}%"));
  105. }
  106. if (!string.IsNullOrWhiteSpace(input.ItemNum))
  107. {
  108. conditions.Add($"(a.ItemNum COLLATE {C}) LIKE (@ItemNum COLLATE {C})");
  109. pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
  110. }
  111. if (!string.IsNullOrWhiteSpace(input.Descr))
  112. {
  113. conditions.Add($"(b.Descr COLLATE {C}) LIKE (@Descr COLLATE {C})");
  114. pars.Add(new SugarParameter("@Descr", $"%{input.Descr.Trim()}%"));
  115. }
  116. if (!string.IsNullOrWhiteSpace(input.OrdDateFrom))
  117. {
  118. conditions.Add("a.OrdDate >= @OrdDateFrom");
  119. pars.Add(new SugarParameter("@OrdDateFrom", input.OrdDateFrom.Trim()));
  120. }
  121. var whereClause = "WHERE " + string.Join(" AND ", conditions);
  122. // mes_morder 与工单的关联:历史上用 factory_id=Domain 直连,但部分库 factory_id 存租户/组织数值而 Domain 为工厂编码,
  123. // 会导致 JOIN 匹配不到、齐套列为空。改为按工单号取齐套,优先 factory_id 与 Domain 文本一致的那条。
  124. var baseSql = $"""
  125. SELECT
  126. a.RecID AS Id,
  127. a.Domain,
  128. a.WorkOrd,
  129. a.Priority,
  130. a.ItemNum,
  131. b.Descr,
  132. b.Descr1,
  133. a.QtyOrded,
  134. m.MaterialSituation,
  135. a.OrdDate,
  136. a.DueDate,
  137. LOWER(a.Status) AS Status,
  138. a.LotSerial,
  139. IFNULL(nm.Nbr,'') AS PrevNbr,
  140. IFNULL(nm1.Nbr,'') AS Nbr,
  141. CONCAT(IFNULL(b.ItemNum,''), IFNULL(b.Descr,''), IFNULL(b.Descr1,'')) AS Wl
  142. FROM WorkOrdMaster a
  143. LEFT JOIN ItemMaster b ON a.ItemNum = b.ItemNum AND (a.tenant_id = b.tenant_id OR a.tenant_id IS NULL)
  144. LEFT JOIN mes_morder m ON a.WorkOrd = m.morder_no
  145. LEFT JOIN NbrMaster nm ON a.WorkOrd= nm.WorkOrd
  146. AND nm.Type = 'SM' AND nm.TransType = 'PrevProcess'
  147. LEFT JOIN NbrMaster nm1 ON a.WorkOrd = nm1.WorkOrd
  148. AND nm1.Type = 'SM' AND nm1.TransType = ''
  149. {whereClause}
  150. """;
  151. var offset = (input.Page - 1) * input.PageSize;
  152. var total = await _db.Ado.GetIntAsync(
  153. $"SELECT COUNT(*) FROM ({baseSql}) AS t", pars);
  154. var list = await _db.Ado.SqlQueryAsync<WorkOrderDispatchRow>(
  155. $"SELECT * FROM ({baseSql}) AS t ORDER BY t.Id DESC LIMIT {input.PageSize} OFFSET {offset}",
  156. pars);
  157. return new { total, page = input.Page, pageSize = input.PageSize, list };
  158. }
  159. // ══════════════════════════════════════════════════════════════
  160. // 下一生产批号 GET /api/WorkOrder/dispatch/next-lot-serial
  161. // 规则:以服务器「当前日期」的 yyMMdd + 三位流水;同一 tenant_id 下按已占用流水递增,避免重复。
  162. // 须存在:tenant_id + 工单号 + 状态 p。
  163. // ══════════════════════════════════════════════════════════════
  164. /// <summary>获取下一可用生产批号(当前日期 + 三位流水)🏭</summary>
  165. [DisplayName("获取下一生产批号")]
  166. [HttpGet("dispatch/next-lot-serial")]
  167. public async Task<object> GetNextLotSerial([FromQuery] long? tenantId, [FromQuery] string workOrd)
  168. {
  169. var tid = ResolveTenantId(tenantId);
  170. if (string.IsNullOrWhiteSpace(workOrd))
  171. throw Oops.Oh("工单编号不能为空");
  172. await AssertInitialWorkOrderExistsAsync(tid, workOrd);
  173. var prefix = DateTime.Today.ToString("yyMMdd", CultureInfo.InvariantCulture);
  174. var maxSql = """
  175. SELECT IFNULL(MAX(CAST(RIGHT(TRIM(w.LotSerial), 3) AS UNSIGNED)), 0)
  176. FROM WorkOrdMaster w
  177. WHERE w.tenant_id = @TenantId
  178. AND TRIM(w.LotSerial) REGEXP '^[0-9]{9}$'
  179. AND LEFT(TRIM(w.LotSerial), 6) = @Prefix
  180. """;
  181. var maxSeq = await _db.Ado.GetIntAsync(maxSql,
  182. new SugarParameter("@TenantId", tid),
  183. new SugarParameter("@Prefix", prefix));
  184. var next = maxSeq + 1;
  185. if (next > 999)
  186. throw Oops.Oh($"当日生产批号流水已超过 999(前缀 {prefix}),请联系管理员");
  187. var lotSerial = prefix + next.ToString("D3", CultureInfo.InvariantCulture);
  188. return new { lotSerial };
  189. }
  190. // ══════════════════════════════════════════════════════════════
  191. // 工单下达 POST /api/WorkOrder/dispatch/release
  192. // ══════════════════════════════════════════════════════════════
  193. /// <summary>工单下达(更新开工日期、生产批号,状态改为 r)。若原开工日、新开工日、原完工日均非空,则完工日按日历同天数平移。</summary>
  194. [DisplayName("工单下达")]
  195. [ApiDescriptionSettings(Name = "ReleaseWorkOrder"), HttpPost("dispatch/release")]
  196. public async Task<object> ReleaseWorkOrder([FromBody] WorkOrderReleaseInput input)
  197. {
  198. var tid = ResolveTenantId(input.TenantId > 0 ? input.TenantId : null);
  199. var wo = input.WorkOrd.Trim();
  200. var logId = await _runLog.StartAsync("S2_WORK_ORDER_RELEASE", tid, "WorkOrdMaster", null, wo);
  201. try
  202. {
  203. await AssertInitialWorkOrderExistsAsync(tid, wo);
  204. // ── 下达前置校验 ──
  205. // 1. 生产批号必须有值
  206. if (string.IsNullOrWhiteSpace(input.LotSerial))
  207. throw Oops.Oh("下达失败!生产批号不能为空,请先获取或输入生产批号。");
  208. // 2. 工单物料明细至少一条
  209. var detailCount = await _db.Ado.GetIntAsync(
  210. """
  211. SELECT COUNT(*) FROM WorkOrdDetail
  212. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  213. """,
  214. new SugarParameter("@TenantId", tid),
  215. new SugarParameter("@WorkOrd", wo));
  216. if (detailCount == 0)
  217. throw Oops.Oh("下达失败!工单物料明细为空,请先同步或维护物料后重试。");
  218. // 3. 工单工序明细至少一条
  219. var routingCount = await _db.Ado.GetIntAsync(
  220. """
  221. SELECT COUNT(*) FROM WorkOrdRouting
  222. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  223. """,
  224. new SugarParameter("@TenantId", tid),
  225. new SugarParameter("@WorkOrd", wo));
  226. if (routingCount == 0)
  227. throw Oops.Oh("下达失败!工单工序明细为空,请先同步工艺路线后重试。");
  228. if (!string.IsNullOrWhiteSpace(input.LotSerial))
  229. {
  230. var ls = input.LotSerial.Trim();
  231. var dup = await _db.Ado.GetIntAsync(
  232. """
  233. SELECT COUNT(*) FROM WorkOrdMaster
  234. WHERE tenant_id = @TenantId
  235. AND TRIM(IFNULL(LotSerial,'')) = @LotSerial
  236. AND WorkOrd <> @WorkOrd
  237. """,
  238. new SugarParameter("@TenantId", tid),
  239. new SugarParameter("@LotSerial", ls),
  240. new SugarParameter("@WorkOrd", wo));
  241. if (dup > 0)
  242. throw Oops.Oh("下达失败!生产批号已被其他工单占用,请重新获取或修改。");
  243. }
  244. DateTime? ordDate = string.IsNullOrWhiteSpace(input.OrdDate)
  245. ? null
  246. : DateTime.Parse(input.OrdDate.Trim(), CultureInfo.InvariantCulture).Date;
  247. var releaseDate = DateTime.Now;
  248. var account = _userManager.Account ?? "system";
  249. var domain = await ResolveWorkOrderDomainAsync(tid, wo);
  250. var kitting = await _kittingCheck.CheckSingleAsync(tid, wo, account, bangId: 0, checkScope: "RELEASE");
  251. // WP9 S4:Mes 权威时先校验 165/保管员,再建领料(失败则本库仍为 p),最后写下达字段
  252. if (_pickBillOpt.IsMes)
  253. await EnsureMesPickPrereqsAsync(tid, domain, wo);
  254. PickBillResult pickResult;
  255. if (_pickBillOpt.IsMes)
  256. {
  257. pickResult = await CreatePickBillForReleaseAsync(tid, domain, wo, account);
  258. await _db.Ado.BeginTranAsync();
  259. try
  260. {
  261. await ApplyReleaseLocalAsync(tid, wo, account, ordDate, input.LotSerial, releaseDate, requireStatusP: false);
  262. await _db.Ado.CommitTranAsync();
  263. }
  264. catch (Exception ex)
  265. {
  266. await _db.Ado.RollbackTranAsync();
  267. throw ThrowReleaseFail(
  268. PickBillUserMessages.ForReleaseFailure(null, "本库下达字段更新失败:" + TruncMsg(ex.Message), "请联系系统管理员。"),
  269. "LOCAL_UPDATE_FAILED", tid, domain, wo, ex);
  270. }
  271. }
  272. else
  273. {
  274. await _db.Ado.BeginTranAsync();
  275. try
  276. {
  277. pickResult = await CreatePickBillForReleaseAsync(tid, domain, wo, account);
  278. await ApplyReleaseLocalAsync(tid, wo, account, ordDate, input.LotSerial, releaseDate, requireStatusP: true);
  279. await _db.Ado.CommitTranAsync();
  280. }
  281. catch (Exception ex)
  282. {
  283. await _db.Ado.RollbackTranAsync();
  284. CaptureDiag("LOCAL_TRAN_FAILED", tid, domain, wo, ex, ex.Message);
  285. throw;
  286. }
  287. }
  288. var response = new
  289. {
  290. message = "工单下达成功",
  291. materialSituation = kitting.MaterialSituation,
  292. pickBillNbr = pickResult.Nbr,
  293. pickBillCreated = pickResult.Created,
  294. pickBillLineCount = pickResult.LineCount,
  295. pickBillSource = pickResult.Source,
  296. pickBillMode = _pickBillOpt.Mode
  297. };
  298. await _runLog.SuccessAsync(logId, "工单下达成功", response);
  299. return response;
  300. }
  301. catch (Exception ex)
  302. {
  303. if (_diag == null)
  304. CaptureDiag(null, tid, null, wo, ex, ex.Message);
  305. await _runLog.FailedAsync(logId, ex.Message, new
  306. {
  307. workOrd = wo,
  308. tenantId = tid,
  309. mode = _pickBillOpt.Mode,
  310. reasonCode = _diag?.ReasonCode,
  311. domain = _diag?.Domain,
  312. userMessage = ex.Message,
  313. exceptionType = _diag?.ExceptionType ?? ex.GetType().FullName,
  314. techDetail = _diag?.TechDetail ?? TruncMsg(ex.ToString(), 4000),
  315. loggedAt = DateTime.Now.ToString("O")
  316. });
  317. throw;
  318. }
  319. }
  320. /// <summary>批量齐套检查(租户下 p/r 工单)。</summary>
  321. [DisplayName("批量齐套检查")]
  322. [HttpPost("dispatch/kitting-check")]
  323. public async Task<object> BatchKittingCheck([FromQuery] string domain, [FromQuery] string? userAccount)
  324. {
  325. var tid = ResolveTenantId(
  326. !string.IsNullOrWhiteSpace(domain) && long.TryParse(domain.Trim(), out var d) && d > 0 ? d : null);
  327. var account = string.IsNullOrWhiteSpace(userAccount)
  328. ? (_userManager.Account ?? "system")
  329. : userAccount.Trim();
  330. var logId = await _runLog.StartAsync("S2_KITTING_CHECK_BATCH", tid, "tenant", tid, domain ?? tid.ToString());
  331. try
  332. {
  333. var result = await _kittingCheck.CheckTenantWorkOrdersAsync(tid, account);
  334. await _runLog.SuccessAsync(logId, "批量齐套检查完成", result);
  335. return new
  336. {
  337. message = "ok",
  338. checkedCount = result.CheckedCount,
  339. kittedCount = result.KittedCount,
  340. shortageCount = result.ShortageCount
  341. };
  342. }
  343. catch (Exception ex)
  344. {
  345. await _runLog.FailedAsync(logId, ex.Message, new { tenantId = tid, domain });
  346. throw;
  347. }
  348. }
  349. /// <summary>单工单齐套检查(下达后或手动触发)。</summary>
  350. [DisplayName("单工单齐套检查")]
  351. [HttpPost("dispatch/work-order-kitting-check")]
  352. public async Task<object> SingleKittingCheck([FromQuery] string workord, [FromQuery] string domain, [FromQuery] string? userAccount)
  353. {
  354. var tid = ResolveTenantId(
  355. !string.IsNullOrWhiteSpace(domain) && long.TryParse(domain.Trim(), out var d) && d > 0 ? d : null);
  356. var account = string.IsNullOrWhiteSpace(userAccount)
  357. ? (_userManager.Account ?? "system")
  358. : userAccount.Trim();
  359. var wo = workord.Trim();
  360. var logId = await _runLog.StartAsync("S2_KITTING_CHECK_SINGLE", tid, "WorkOrdMaster", null, wo);
  361. try
  362. {
  363. var result = await _kittingCheck.CheckSingleAsync(tid, wo, account, bangId: 0, checkScope: "SINGLE");
  364. // 缺料是正常业务结论,不是接口失败:照常返回,由前端以告警形式呈现并刷新列表
  365. await _runLog.SuccessAsync(
  366. logId,
  367. result.IsKitted ? "单工单齐套检查通过" : $"单工单齐套检查完成(缺料行 {result.ShortageLineCount})",
  368. result);
  369. return new
  370. {
  371. message = "ok",
  372. materialSituation = result.MaterialSituation,
  373. isKitted = result.IsKitted,
  374. shortageLineCount = result.ShortageLineCount
  375. };
  376. }
  377. catch (Exception ex)
  378. {
  379. await _runLog.FailedAsync(logId, ex.Message, new { workOrd = wo, tenantId = tid });
  380. throw;
  381. }
  382. }
  383. private async Task<string> ResolveWorkOrderDomainAsync(long tenantId, string workOrd)
  384. {
  385. var domain = await _db.Ado.GetStringAsync(
  386. """
  387. SELECT TRIM(IFNULL(`Domain`, '')) FROM WorkOrdMaster
  388. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1
  389. """,
  390. new List<SugarParameter>
  391. {
  392. new("@TenantId", tenantId),
  393. new("@WorkOrd", workOrd)
  394. });
  395. return string.IsNullOrWhiteSpace(domain) ? "8010" : domain;
  396. }
  397. private async Task<PickBillResult> CreatePickBillForReleaseAsync(
  398. long tenantId, string domain, string workOrd, string account)
  399. {
  400. try
  401. {
  402. return await _pickBillCreator.CreateAsync(tenantId, domain, workOrd, account);
  403. }
  404. catch (Exception ex)
  405. {
  406. var raw = ex.Message ?? "";
  407. var reasonCode = GuessReasonCode(raw);
  408. if (raw.Contains("下达失败", StringComparison.Ordinal) || raw.Contains("建领料失败", StringComparison.Ordinal))
  409. {
  410. throw ThrowReleaseFail(
  411. raw.Replace("建领料失败!", "下达失败!", StringComparison.Ordinal),
  412. reasonCode, tenantId, domain, workOrd, ex);
  413. }
  414. throw ThrowReleaseFail(
  415. PickBillUserMessages.ForReleaseFailure(reasonCode, raw, "请联系系统管理员。"),
  416. reasonCode ?? "PICK_CREATE_FAILED", tenantId, domain, workOrd, ex);
  417. }
  418. }
  419. /// <summary>WP9 S4:下达前校验 165 可达 + 保管员可解析。</summary>
  420. private async Task EnsureMesPickPrereqsAsync(long tenantId, string domain, string workOrd)
  421. {
  422. try
  423. {
  424. await _scopeFactory.GetScopeAsync(CreatePickBillService.TargetSource);
  425. }
  426. catch (Exception ex)
  427. {
  428. throw ThrowReleaseFail(
  429. PickBillUserMessages.ForReleaseFailure("MES_UNREACHABLE"),
  430. "MES_UNREACHABLE", tenantId, domain, workOrd, ex);
  431. }
  432. var itemNums = await _db.Ado.SqlQueryAsync<string>(
  433. """
  434. SELECT DISTINCT TRIM(ItemNum) AS ItemNum
  435. FROM WorkOrdDetail
  436. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  437. AND IFNULL(IsActive, 0) = 1 AND IFNULL(ItemNum, '') <> ''
  438. """,
  439. new SugarParameter("@TenantId", tenantId),
  440. new SugarParameter("@WorkOrd", workOrd));
  441. if (itemNums.Count == 0)
  442. throw ThrowReleaseFail(
  443. "下达失败!工单物料明细为空,请先同步或维护物料后重试。",
  444. "NO_DETAIL", tenantId, domain, workOrd);
  445. try
  446. {
  447. await _workOrdMesPush.AssertReadyOnMesAsync(domain, workOrd);
  448. }
  449. catch (Exception ex)
  450. {
  451. var code = ex.Message.Contains("尚未具备完整工单", StringComparison.Ordinal)
  452. ? "WO_MES_NOT_READY"
  453. : "MES_UNREACHABLE";
  454. throw ThrowReleaseFail(
  455. PickBillUserMessages.ForReleaseFailure(code),
  456. code, tenantId, domain, workOrd, ex);
  457. }
  458. KeeperResolveService.KeeperResolveResult keeper;
  459. try
  460. {
  461. keeper = await _keeper.ResolveByItemsAsync(domain, itemNums);
  462. }
  463. catch (Exception ex)
  464. {
  465. throw ThrowReleaseFail(
  466. PickBillUserMessages.ForReleaseFailure("MES_UNREACHABLE"),
  467. "MES_UNREACHABLE", tenantId, domain, workOrd, ex);
  468. }
  469. if (keeper.UnsafeItems.Count > 0)
  470. throw ThrowReleaseFail(
  471. PickBillUserMessages.ForReleaseFailure("KEEPER_RULE_UNSAFE", items: keeper.UnsafeItems),
  472. "KEEPER_RULE_UNSAFE", tenantId, domain, workOrd,
  473. techNote: "unsafeItems=" + string.Join(",", keeper.UnsafeItems.Take(30)));
  474. if (keeper.EmptyLocationItems.Count > 0)
  475. throw ThrowReleaseFail(
  476. PickBillUserMessages.ForReleaseFailure("NO_KEEPER", items: keeper.EmptyLocationItems),
  477. "NO_KEEPER", tenantId, domain, workOrd,
  478. techNote: "emptyLocationItems=" + string.Join(",", keeper.EmptyLocationItems.Take(30)));
  479. if (keeper.MissingItems.Count > 0)
  480. throw ThrowReleaseFail(
  481. PickBillUserMessages.ForReleaseFailure("NO_KEEPER", items: keeper.MissingItems),
  482. "NO_KEEPER", tenantId, domain, workOrd,
  483. techNote: "missingItems=" + string.Join(",", keeper.MissingItems.Take(30)));
  484. }
  485. private Exception ThrowReleaseFail(
  486. string userMessage,
  487. string? reasonCode,
  488. long tenantId,
  489. string? domain,
  490. string workOrd,
  491. Exception? techEx = null,
  492. string? techNote = null)
  493. {
  494. CaptureDiag(reasonCode, tenantId, domain, workOrd, techEx, userMessage, techNote);
  495. return Oops.Oh(userMessage);
  496. }
  497. private void CaptureDiag(
  498. string? reasonCode,
  499. long tenantId,
  500. string? domain,
  501. string workOrd,
  502. Exception? techEx,
  503. string? userMessage,
  504. string? techNote = null)
  505. {
  506. var tech = techEx != null
  507. ? TruncMsg(techEx.ToString(), 4000)
  508. : TruncMsg(techNote, 2000);
  509. _diag = new ReleaseDiagContext
  510. {
  511. ReasonCode = reasonCode,
  512. Domain = domain,
  513. ExceptionType = techEx?.GetType().FullName,
  514. TechDetail = tech,
  515. UserMessage = userMessage
  516. };
  517. if (techEx != null)
  518. {
  519. _logger.LogError(
  520. techEx,
  521. "[WorkOrderRelease] reasonCode={ReasonCode} workOrd={WorkOrd} tenantId={TenantId} domain={Domain} mode={Mode} userMsg={UserMsg}",
  522. reasonCode, workOrd, tenantId, domain, _pickBillOpt.Mode, userMessage);
  523. }
  524. else
  525. {
  526. _logger.LogWarning(
  527. "[WorkOrderRelease] reasonCode={ReasonCode} workOrd={WorkOrd} tenantId={TenantId} domain={Domain} mode={Mode} userMsg={UserMsg} note={Note}",
  528. reasonCode, workOrd, tenantId, domain, _pickBillOpt.Mode, userMessage, techNote);
  529. }
  530. }
  531. private static string? GuessReasonCode(string message)
  532. {
  533. if (message.Contains("无法连接 MES", StringComparison.Ordinal) ||
  534. message.Contains("MES_UNREACHABLE", StringComparison.OrdinalIgnoreCase))
  535. return "MES_UNREACHABLE";
  536. if (message.Contains("保管员", StringComparison.Ordinal) ||
  537. message.Contains("NO_KEEPER", StringComparison.OrdinalIgnoreCase))
  538. return "NO_KEEPER";
  539. if (message.Contains("KEEPER_RULE_UNSAFE", StringComparison.OrdinalIgnoreCase) ||
  540. message.Contains("不支持的字符", StringComparison.Ordinal))
  541. return "KEEPER_RULE_UNSAFE";
  542. if (message.Contains("取号", StringComparison.Ordinal) ||
  543. message.Contains("SEQ_FAILED", StringComparison.OrdinalIgnoreCase))
  544. return "SEQ_FAILED";
  545. return null;
  546. }
  547. private static string TruncMsg(string? s, int max = 120)
  548. {
  549. s ??= "";
  550. return s.Length <= max ? s : s[..max] + "…";
  551. }
  552. private sealed class ReleaseDiagContext
  553. {
  554. public string? ReasonCode { get; set; }
  555. public string? Domain { get; set; }
  556. public string? ExceptionType { get; set; }
  557. public string? TechDetail { get; set; }
  558. public string? UserMessage { get; set; }
  559. }
  560. /// <summary>
  561. /// 本库下达字段:Status/批号/日期/里程碑工序/mes_morder。
  562. /// Mes 路径下建单服务可能已将 Status 置 r,故 <paramref name="requireStatusP"/> 为 false。
  563. /// </summary>
  564. private async Task ApplyReleaseLocalAsync(
  565. long tenantId,
  566. string workOrd,
  567. string account,
  568. DateTime? ordDate,
  569. string? lotSerial,
  570. DateTime releaseDate,
  571. bool requireStatusP)
  572. {
  573. var updatePars = new List<SugarParameter>
  574. {
  575. new("@Status", "r"),
  576. new("@OrdDate", ordDate ?? (object)DBNull.Value),
  577. new("@LotSerial", string.IsNullOrWhiteSpace(lotSerial) ? (object)DBNull.Value : lotSerial.Trim()),
  578. new("@ReleaseDate", releaseDate),
  579. new("@UpdateUser", account),
  580. new("@UpdateTime", releaseDate),
  581. new("@WorkOrd", workOrd),
  582. new("@TenantId", tenantId)
  583. };
  584. var statusFilter = requireStatusP
  585. ? " AND LOWER(TRIM(IFNULL(Status,''))) = 'p'"
  586. : "";
  587. var affected = await _db.Ado.ExecuteCommandAsync(
  588. $"""
  589. UPDATE WorkOrdMaster
  590. SET Status = @Status,
  591. OrdDate = @OrdDate,
  592. DueDate = CASE
  593. WHEN OrdDate IS NOT NULL AND @OrdDate IS NOT NULL AND DueDate IS NOT NULL THEN
  594. DATE_ADD(DATE(DueDate), INTERVAL DATEDIFF(DATE(@OrdDate), DATE(OrdDate)) DAY)
  595. ELSE DueDate
  596. END,
  597. LotSerial = @LotSerial,
  598. ReleaseDate = @ReleaseDate,
  599. Eff = @ReleaseDate,
  600. UpdateUser = @UpdateUser,
  601. UpdateTime = @UpdateTime,
  602. Batch = SUBSTRING(@WorkOrd, 2)
  603. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  604. {statusFilter}
  605. """,
  606. updatePars);
  607. if (affected == 0)
  608. throw Oops.Oh("下达失败!未更新到对应工单,请确认工单仍为初始状态后重试。");
  609. await _db.Ado.ExecuteCommandAsync(
  610. """
  611. UPDATE WorkOrdRouting
  612. SET Status = 'r', UpdateUser = @User, UpdateTime = @Now
  613. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  614. AND IFNULL(MilestoneOp, 0) = 1 AND IFNULL(IsActive, 0) = 1
  615. """,
  616. new SugarParameter("@User", account),
  617. new SugarParameter("@Now", releaseDate),
  618. new SugarParameter("@TenantId", tenantId),
  619. new SugarParameter("@WorkOrd", workOrd));
  620. await _db.Ado.ExecuteCommandAsync(
  621. """
  622. UPDATE mes_morder
  623. SET morder_state = '下达',
  624. MaterialSituation = CASE
  625. WHEN MaterialSituation = '缺料' THEN '未备料'
  626. ELSE IFNULL(MaterialSituation, '齐套')
  627. END,
  628. update_by_name = @User,
  629. update_time = @Now
  630. WHERE tenant_id = @TenantId AND morder_no = @WorkOrd AND IsDeleted = 0
  631. """,
  632. new SugarParameter("@User", account),
  633. new SugarParameter("@Now", releaseDate),
  634. new SugarParameter("@TenantId", tenantId),
  635. new SugarParameter("@WorkOrd", workOrd));
  636. }
  637. // ──────────────── 内部查询结果映射类 ────────────────
  638. private sealed class WorkOrderDispatchRow
  639. {
  640. public int Id { get; set; }
  641. public string? Domain { get; set; }
  642. public string? WorkOrd { get; set; }
  643. public string? Priority { get; set; }
  644. public string? ItemNum { get; set; }
  645. public string? Descr { get; set; }
  646. public string? Descr1 { get; set; }
  647. public decimal? QtyOrded { get; set; }
  648. public string? MaterialSituation { get; set; }
  649. public DateTime? OrdDate { get; set; }
  650. public DateTime? DueDate { get; set; }
  651. public string? Status { get; set; }
  652. public string? LotSerial { get; set; }
  653. public string? PrevNbr { get; set; }
  654. public string? Nbr { get; set; }
  655. public string? Wl { get; set; }
  656. }
  657. }