WorkOrderDispatchService.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. using System.Globalization;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. namespace Admin.NET.Plugin.AiDOP.WorkOrder;
  4. /// <summary>
  5. /// 工单下达服务 🏭
  6. /// 路由前缀:/api/WorkOrder/dispatch/...
  7. /// </summary>
  8. [ApiDescriptionSettings(Order = 260, Description = "工单下达")]
  9. [Route("api/WorkOrder")]
  10. [AllowAnonymous]
  11. [NonUnify]
  12. public class WorkOrderDispatchService : IDynamicApiController, ITransient
  13. {
  14. private readonly ISqlSugarClient _db;
  15. private readonly UserManager _userManager;
  16. private readonly WorkOrderKittingCheckService _kittingCheck;
  17. private readonly WorkOrderPickBillService _pickBill;
  18. private readonly AidopActionRunLogWriter _runLog;
  19. public WorkOrderDispatchService(
  20. ISqlSugarClient db,
  21. UserManager userManager,
  22. WorkOrderKittingCheckService kittingCheck,
  23. WorkOrderPickBillService pickBill,
  24. AidopActionRunLogWriter runLog)
  25. {
  26. _db = db;
  27. _userManager = userManager;
  28. _kittingCheck = kittingCheck;
  29. _pickBill = pickBill;
  30. _runLog = runLog;
  31. }
  32. private long ResolveTenantId(long? requestTenantId) =>
  33. AidopTenantHelper.Resolve(App.HttpContext, requestTenantId);
  34. /// <summary>校验:本租户下存在处于初始状态(p)的该工单。</summary>
  35. private async Task AssertInitialWorkOrderExistsAsync(long tenantId, string workOrd)
  36. {
  37. var wo = workOrd.Trim();
  38. var cnt = await _db.Ado.GetIntAsync(
  39. """
  40. SELECT COUNT(*) FROM WorkOrdMaster
  41. WHERE tenant_id = @TenantId
  42. AND WorkOrd = @WorkOrd
  43. AND LOWER(TRIM(IFNULL(Status,''))) = 'p'
  44. """,
  45. new SugarParameter("@TenantId", tenantId),
  46. new SugarParameter("@WorkOrd", wo));
  47. if (cnt == 0)
  48. throw Oops.Oh("未找到处于初始状态(p)的本租户工单,请确认工单号与租户是否正确");
  49. }
  50. // ══════════════════════════════════════════════════════════════
  51. // 列表 GET /api/WorkOrder/dispatch/list
  52. // ══════════════════════════════════════════════════════════════
  53. /// <summary>获取工单下达分页列表 🏭</summary>
  54. [DisplayName("获取工单下达列表")]
  55. [HttpGet("dispatch/list")]
  56. public async Task<object> GetDispatchList([FromQuery] WorkOrderDispatchListInput input)
  57. {
  58. var tid = AidopTenantHelper.Resolve(App.HttpContext);
  59. var pars = new List<SugarParameter> { new SugarParameter("@TenantId", tid) };
  60. // 与库内其它原生 SQL 一致:统一 COLLATE,避免 utf8mb4_general_ci / utf8mb4_0900_ai_ci 混用报错
  61. const string C = "utf8mb4_general_ci";
  62. var conditions = new List<string> { $"a.tenant_id = @TenantId", $"a.Status COLLATE {C} = 'p'" };
  63. if (!string.IsNullOrWhiteSpace(input.WorkOrd))
  64. {
  65. conditions.Add($"(a.WorkOrd COLLATE {C}) LIKE (@WorkOrd COLLATE {C})");
  66. pars.Add(new SugarParameter("@WorkOrd", $"%{input.WorkOrd.Trim()}%"));
  67. }
  68. if (!string.IsNullOrWhiteSpace(input.ItemNum))
  69. {
  70. conditions.Add($"(a.ItemNum COLLATE {C}) LIKE (@ItemNum COLLATE {C})");
  71. pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
  72. }
  73. if (!string.IsNullOrWhiteSpace(input.Descr))
  74. {
  75. conditions.Add($"(b.Descr COLLATE {C}) LIKE (@Descr COLLATE {C})");
  76. pars.Add(new SugarParameter("@Descr", $"%{input.Descr.Trim()}%"));
  77. }
  78. if (!string.IsNullOrWhiteSpace(input.OrdDateFrom))
  79. {
  80. conditions.Add("a.OrdDate >= @OrdDateFrom");
  81. pars.Add(new SugarParameter("@OrdDateFrom", input.OrdDateFrom.Trim()));
  82. }
  83. var whereClause = "WHERE " + string.Join(" AND ", conditions);
  84. // mes_morder 与工单的关联:历史上用 factory_id=Domain 直连,但部分库 factory_id 存租户/组织数值而 Domain 为工厂编码,
  85. // 会导致 JOIN 匹配不到、齐套列为空。改为按工单号取齐套,优先 factory_id 与 Domain 文本一致的那条。
  86. var baseSql = $"""
  87. SELECT
  88. a.RecID AS Id,
  89. a.Domain,
  90. a.WorkOrd,
  91. a.Priority,
  92. a.ItemNum,
  93. b.Descr,
  94. b.Descr1,
  95. a.QtyOrded,
  96. m.MaterialSituation,
  97. a.OrdDate,
  98. a.DueDate,
  99. LOWER(a.Status) AS Status,
  100. a.LotSerial,
  101. IFNULL(nm.Nbr,'') AS PrevNbr,
  102. IFNULL(nm1.Nbr,'') AS Nbr,
  103. CONCAT(IFNULL(b.ItemNum,''), IFNULL(b.Descr,''), IFNULL(b.Descr1,'')) AS Wl
  104. FROM WorkOrdMaster a
  105. LEFT JOIN ItemMaster b ON a.ItemNum = b.ItemNum
  106. LEFT JOIN mes_morder m ON a.WorkOrd = m.morder_no
  107. LEFT JOIN NbrMaster nm ON a.WorkOrd= nm.WorkOrd
  108. AND nm.Type = 'SM' AND nm.TransType = 'PrevProcess'
  109. LEFT JOIN NbrMaster nm1 ON a.WorkOrd = nm1.WorkOrd
  110. AND nm1.Type = 'SM' AND nm1.TransType = ''
  111. {whereClause}
  112. """;
  113. var offset = (input.Page - 1) * input.PageSize;
  114. var total = await _db.Ado.GetIntAsync(
  115. $"SELECT COUNT(*) FROM ({baseSql}) AS t", pars);
  116. var list = await _db.Ado.SqlQueryAsync<WorkOrderDispatchRow>(
  117. $"SELECT * FROM ({baseSql}) AS t ORDER BY t.Id DESC LIMIT {input.PageSize} OFFSET {offset}",
  118. pars);
  119. return new { total, page = input.Page, pageSize = input.PageSize, list };
  120. }
  121. // ══════════════════════════════════════════════════════════════
  122. // 下一生产批号 GET /api/WorkOrder/dispatch/next-lot-serial
  123. // 规则:以服务器「当前日期」的 yyMMdd + 三位流水;同一 tenant_id 下按已占用流水递增,避免重复。
  124. // 须存在:tenant_id + 工单号 + 状态 p。
  125. // ══════════════════════════════════════════════════════════════
  126. /// <summary>获取下一可用生产批号(当前日期 + 三位流水)🏭</summary>
  127. [DisplayName("获取下一生产批号")]
  128. [HttpGet("dispatch/next-lot-serial")]
  129. public async Task<object> GetNextLotSerial([FromQuery] long? tenantId, [FromQuery] string workOrd)
  130. {
  131. var tid = ResolveTenantId(tenantId);
  132. if (string.IsNullOrWhiteSpace(workOrd))
  133. throw Oops.Oh("工单编号不能为空");
  134. await AssertInitialWorkOrderExistsAsync(tid, workOrd);
  135. var prefix = DateTime.Today.ToString("yyMMdd", CultureInfo.InvariantCulture);
  136. var maxSql = """
  137. SELECT IFNULL(MAX(CAST(RIGHT(TRIM(w.LotSerial), 3) AS UNSIGNED)), 0)
  138. FROM WorkOrdMaster w
  139. WHERE w.tenant_id = @TenantId
  140. AND TRIM(w.LotSerial) REGEXP '^[0-9]{9}$'
  141. AND LEFT(TRIM(w.LotSerial), 6) = @Prefix
  142. """;
  143. var maxSeq = await _db.Ado.GetIntAsync(maxSql,
  144. new SugarParameter("@TenantId", tid),
  145. new SugarParameter("@Prefix", prefix));
  146. var next = maxSeq + 1;
  147. if (next > 999)
  148. throw Oops.Oh($"当日生产批号流水已超过 999(前缀 {prefix}),请联系管理员");
  149. var lotSerial = prefix + next.ToString("D3", CultureInfo.InvariantCulture);
  150. return new { lotSerial };
  151. }
  152. // ══════════════════════════════════════════════════════════════
  153. // 工单下达 POST /api/WorkOrder/dispatch/release
  154. // ══════════════════════════════════════════════════════════════
  155. /// <summary>工单下达(更新开工日期、生产批号,状态改为 r)。若原开工日、新开工日、原完工日均非空,则完工日按日历同天数平移。</summary>
  156. [DisplayName("工单下达")]
  157. [ApiDescriptionSettings(Name = "ReleaseWorkOrder"), HttpPost("dispatch/release")]
  158. public async Task<object> ReleaseWorkOrder([FromBody] WorkOrderReleaseInput input)
  159. {
  160. var tid = ResolveTenantId(input.TenantId > 0 ? input.TenantId : null);
  161. var wo = input.WorkOrd.Trim();
  162. var logId = await _runLog.StartAsync("S2_WORK_ORDER_RELEASE", tid, "WorkOrdMaster", null, wo);
  163. try
  164. {
  165. await AssertInitialWorkOrderExistsAsync(tid, wo);
  166. if (!string.IsNullOrWhiteSpace(input.LotSerial))
  167. {
  168. var ls = input.LotSerial.Trim();
  169. var dup = await _db.Ado.GetIntAsync(
  170. """
  171. SELECT COUNT(*) FROM WorkOrdMaster
  172. WHERE tenant_id = @TenantId
  173. AND TRIM(IFNULL(LotSerial,'')) = @LotSerial
  174. AND WorkOrd <> @WorkOrd
  175. """,
  176. new SugarParameter("@TenantId", tid),
  177. new SugarParameter("@LotSerial", ls),
  178. new SugarParameter("@WorkOrd", wo));
  179. if (dup > 0)
  180. throw Oops.Oh("生产批号已被本租户下其他工单占用,请重新获取或修改");
  181. }
  182. DateTime? ordDate = string.IsNullOrWhiteSpace(input.OrdDate)
  183. ? null
  184. : DateTime.Parse(input.OrdDate.Trim(), CultureInfo.InvariantCulture).Date;
  185. var releaseDate = DateTime.Now;
  186. var account = _userManager.Account ?? "system";
  187. var kitting = await _kittingCheck.CheckSingleAsync(tid, wo, account);
  188. if (!kitting.IsKitted)
  189. throw Oops.Oh($"工单 {wo} 齐套检查未通过(缺料行 {kitting.ShortageLineCount}),请先完成资源检查或补料");
  190. WorkOrderPickBillService.PickBillResult pickResult;
  191. await _db.Ado.BeginTranAsync();
  192. try
  193. {
  194. pickResult = await _pickBill.CreateForWorkOrderAsync(tid, wo, account);
  195. var updatePars = new List<SugarParameter>
  196. {
  197. new SugarParameter("@Status", "r"),
  198. new SugarParameter("@OrdDate", ordDate ?? (object)DBNull.Value),
  199. new SugarParameter("@LotSerial", string.IsNullOrWhiteSpace(input.LotSerial) ? (object)DBNull.Value : input.LotSerial.Trim()),
  200. new SugarParameter("@ReleaseDate", releaseDate),
  201. new SugarParameter("@UpdateUser", account),
  202. new SugarParameter("@UpdateTime", releaseDate),
  203. new SugarParameter("@WorkOrd", wo),
  204. new SugarParameter("@TenantId", tid)
  205. };
  206. var affected = await _db.Ado.ExecuteCommandAsync(
  207. """
  208. UPDATE WorkOrdMaster
  209. SET Status = @Status,
  210. OrdDate = @OrdDate,
  211. DueDate = CASE
  212. WHEN OrdDate IS NOT NULL AND @OrdDate IS NOT NULL AND DueDate IS NOT NULL THEN
  213. DATE_ADD(DATE(DueDate), INTERVAL DATEDIFF(DATE(@OrdDate), DATE(OrdDate)) DAY)
  214. ELSE DueDate
  215. END,
  216. LotSerial = @LotSerial,
  217. ReleaseDate = @ReleaseDate,
  218. UpdateUser = @UpdateUser,
  219. UpdateTime = @UpdateTime,
  220. Batch = SUBSTRING(@WorkOrd, 2)
  221. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  222. """,
  223. updatePars);
  224. if (affected == 0)
  225. throw Oops.Oh("工单下达失败:未更新到对应工单,请确认工单号、租户及状态仍为初始(p)");
  226. await _db.Ado.ExecuteCommandAsync(
  227. """
  228. UPDATE WorkOrdRouting
  229. SET Status = 'r', UpdateUser = @User, UpdateTime = @Now
  230. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  231. AND IFNULL(MilestoneOp, 0) = 1 AND IFNULL(IsActive, 0) = 1
  232. """,
  233. new SugarParameter("@User", account),
  234. new SugarParameter("@Now", releaseDate),
  235. new SugarParameter("@TenantId", tid),
  236. new SugarParameter("@WorkOrd", wo));
  237. await _db.Ado.CommitTranAsync();
  238. }
  239. catch
  240. {
  241. await _db.Ado.RollbackTranAsync();
  242. throw;
  243. }
  244. var response = new
  245. {
  246. message = "工单下达成功",
  247. materialSituation = kitting.MaterialSituation,
  248. pickBillNbr = pickResult.Nbr,
  249. pickBillCreated = pickResult.Created,
  250. pickBillLineCount = pickResult.LineCount
  251. };
  252. await _runLog.SuccessAsync(logId, "工单下达成功", response);
  253. return response;
  254. }
  255. catch (Exception ex)
  256. {
  257. await _runLog.FailedAsync(logId, ex.Message, new { workOrd = wo, tenantId = tid });
  258. throw;
  259. }
  260. }
  261. /// <summary>批量齐套检查(租户下 p/r 工单)。</summary>
  262. [DisplayName("批量齐套检查")]
  263. [HttpPost("dispatch/kitting-check")]
  264. public async Task<object> BatchKittingCheck([FromQuery] string domain, [FromQuery] string? userAccount)
  265. {
  266. var tid = ResolveTenantId(
  267. !string.IsNullOrWhiteSpace(domain) && long.TryParse(domain.Trim(), out var d) && d > 0 ? d : null);
  268. var account = string.IsNullOrWhiteSpace(userAccount)
  269. ? (_userManager.Account ?? "system")
  270. : userAccount.Trim();
  271. var logId = await _runLog.StartAsync("S2_KITTING_CHECK_BATCH", tid, "tenant", tid, domain ?? tid.ToString());
  272. try
  273. {
  274. var result = await _kittingCheck.CheckTenantWorkOrdersAsync(tid, account);
  275. await _runLog.SuccessAsync(logId, "批量齐套检查完成", result);
  276. return new
  277. {
  278. message = "ok",
  279. checkedCount = result.CheckedCount,
  280. kittedCount = result.KittedCount,
  281. shortageCount = result.ShortageCount
  282. };
  283. }
  284. catch (Exception ex)
  285. {
  286. await _runLog.FailedAsync(logId, ex.Message, new { tenantId = tid, domain });
  287. throw;
  288. }
  289. }
  290. /// <summary>单工单齐套检查(下达后或手动触发)。</summary>
  291. [DisplayName("单工单齐套检查")]
  292. [HttpPost("dispatch/work-order-kitting-check")]
  293. public async Task<object> SingleKittingCheck([FromQuery] string workord, [FromQuery] string domain, [FromQuery] string? userAccount)
  294. {
  295. var tid = ResolveTenantId(
  296. !string.IsNullOrWhiteSpace(domain) && long.TryParse(domain.Trim(), out var d) && d > 0 ? d : null);
  297. var account = string.IsNullOrWhiteSpace(userAccount)
  298. ? (_userManager.Account ?? "system")
  299. : userAccount.Trim();
  300. var wo = workord.Trim();
  301. var logId = await _runLog.StartAsync("S2_KITTING_CHECK_SINGLE", tid, "WorkOrdMaster", null, wo);
  302. try
  303. {
  304. var result = await _kittingCheck.CheckSingleAsync(tid, wo, account);
  305. if (!result.IsKitted)
  306. {
  307. await _runLog.FailedAsync(logId, $"缺料行 {result.ShortageLineCount}", result);
  308. throw Oops.Oh($"工单 {workord} 齐套检查未通过(缺料行 {result.ShortageLineCount})");
  309. }
  310. await _runLog.SuccessAsync(logId, "单工单齐套检查通过", result);
  311. return new { message = "ok", materialSituation = result.MaterialSituation };
  312. }
  313. catch (Exception ex)
  314. {
  315. if (ex.Message.Contains("齐套检查未通过", StringComparison.Ordinal))
  316. throw;
  317. await _runLog.FailedAsync(logId, ex.Message, new { workOrd = wo, tenantId = tid });
  318. throw;
  319. }
  320. }
  321. // ──────────────── 内部查询结果映射类 ────────────────
  322. private sealed class WorkOrderDispatchRow
  323. {
  324. public int Id { get; set; }
  325. public string? Domain { get; set; }
  326. public string? WorkOrd { get; set; }
  327. public string? Priority { get; set; }
  328. public string? ItemNum { get; set; }
  329. public string? Descr { get; set; }
  330. public string? Descr1 { get; set; }
  331. public decimal? QtyOrded { get; set; }
  332. public string? MaterialSituation { get; set; }
  333. public DateTime? OrdDate { get; set; }
  334. public DateTime? DueDate { get; set; }
  335. public string? Status { get; set; }
  336. public string? LotSerial { get; set; }
  337. public string? PrevNbr { get; set; }
  338. public string? Nbr { get; set; }
  339. public string? Wl { get; set; }
  340. }
  341. }