SmartOpsImprovementActionService.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. using System.Text.Json;
  2. using Admin.NET.Core;
  3. using Admin.NET.Core.Service;
  4. using Admin.NET.Plugin.AiDOP.Controllers;
  5. using Admin.NET.Plugin.AiDOP.Entity;
  6. using Admin.NET.Plugin.AiDOP.Infrastructure;
  7. using Microsoft.Extensions.Logging;
  8. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  9. public sealed class SmartOpsImprovementActionService : ITransient
  10. {
  11. private static readonly JsonSerializerOptions JsonOptions = new()
  12. {
  13. PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
  14. PropertyNameCaseInsensitive = true,
  15. };
  16. private readonly ISqlSugarClient _db;
  17. private readonly SysNoticeService _noticeService;
  18. private readonly ILogger<SmartOpsImprovementActionService> _logger;
  19. public SmartOpsImprovementActionService(
  20. ISqlSugarClient db,
  21. SysNoticeService noticeService,
  22. ILogger<SmartOpsImprovementActionService> logger)
  23. {
  24. _db = db;
  25. _noticeService = noticeService;
  26. _logger = logger;
  27. }
  28. public async Task<List<AdoSmartOpsImprovementAction>> ListByPlanAsync(long tenantId, long planId)
  29. {
  30. await MigrateLegacyJsonAsync(tenantId, planId);
  31. return await _db.Queryable<AdoSmartOpsImprovementAction>()
  32. .Where(x => x.TenantId == tenantId && x.PlanId == planId)
  33. .OrderBy(x => x.SequenceNo)
  34. .ToListAsync();
  35. }
  36. public async Task<List<AdoSmartOpsImprovementAction>> ListMineAsync(SmartOpsTrustedScope scope)
  37. {
  38. return await _db.Queryable<AdoSmartOpsImprovementAction>()
  39. .InnerJoin<AdoSmartOpsImprovementPlan>((a, p) => a.PlanId == p.Id && a.TenantId == p.TenantId)
  40. .Where((a, p) => a.TenantId == scope.TenantId && a.OwnerUserId == scope.UserId)
  41. .Where((a, p) => p.Status == "executing" || p.Status == "pending_verify")
  42. .OrderBy((a, p) => a.DueDate)
  43. .Select((a, p) => a)
  44. .ToListAsync();
  45. }
  46. public async Task NotifyAssignedForPlanAsync(AdoSmartOpsImprovementPlan plan, long operatorUserId)
  47. {
  48. var rows = await _db.Queryable<AdoSmartOpsImprovementAction>()
  49. .Where(x => x.TenantId == plan.TenantId && x.PlanId == plan.Id && x.OwnerUserId > 0)
  50. .ToListAsync();
  51. foreach (var row in rows)
  52. await NotifyAsync(plan, row, SmartOpsImprovementNoticeType.Assigned, operatorUserId);
  53. }
  54. public async Task NotifyOwnerToStartAsync(AdoSmartOpsImprovementPlan plan, long operatorUserId)
  55. {
  56. if (plan.OwnerUserId is not > 0) return;
  57. var jump = $"/#/aidop/smart-ops/improvement-plans?id={plan.Id}";
  58. var content =
  59. $"<p>改善计划 {plan.PlanNo} 已审批通过,请作为总负责人开始执行。开始后系统会把各行动项分派给对应责任人。</p>" +
  60. $"<p><a href=\"{jump}\">打开计划详情</a></p>";
  61. try
  62. {
  63. await _noticeService.PublishToUsersAsync(
  64. $"改善计划待开始执行 {plan.PlanNo}",
  65. content,
  66. new[] { plan.OwnerUserId.Value },
  67. operatorUserId > 0 ? operatorUserId : plan.OwnerUserId.Value,
  68. "智慧诊断");
  69. }
  70. catch (Exception ex)
  71. {
  72. _logger.LogWarning(ex, "改善计划开始执行通知失败 planId={PlanId}", plan.Id);
  73. }
  74. }
  75. public async Task<long?> ResolveOwnerUserIdAsync(AdoSmartOpsImprovementPlan plan)
  76. {
  77. if (plan.OwnerUserId is > 0) return plan.OwnerUserId;
  78. var first = await _db.Queryable<AdoSmartOpsImprovementAction>()
  79. .Where(x => x.TenantId == plan.TenantId && x.PlanId == plan.Id && x.OwnerUserId > 0)
  80. .OrderBy(x => x.SequenceNo)
  81. .FirstAsync();
  82. return first?.OwnerUserId;
  83. }
  84. public async Task ReplaceFromDraftAsync(
  85. AdoSmartOpsImprovementPlan plan,
  86. IReadOnlyList<ImprovementActionItem> drafts,
  87. SmartOpsTrustedScope scope)
  88. {
  89. var existing = await _db.Queryable<AdoSmartOpsImprovementAction>()
  90. .Where(x => x.TenantId == plan.TenantId && x.PlanId == plan.Id)
  91. .ToListAsync();
  92. if (existing.Count > 0)
  93. return;
  94. var now = DateTime.Now;
  95. var rows = new List<AdoSmartOpsImprovementAction>();
  96. for (var i = 0; i < drafts.Count; i++)
  97. {
  98. var draft = drafts[i];
  99. if (string.IsNullOrWhiteSpace(draft.Content)) continue;
  100. var ownerUserId = draft.OwnerUserId is > 0 ? draft.OwnerUserId : null;
  101. if (ownerUserId is > 0)
  102. await EnsureOwnerInTenantAsync(plan.TenantId ?? 0, ownerUserId);
  103. rows.Add(new AdoSmartOpsImprovementAction
  104. {
  105. TenantId = plan.TenantId,
  106. FactoryId = plan.FactoryId,
  107. PlanId = plan.Id,
  108. SequenceNo = rows.Count + 1,
  109. Content = draft.Content.Trim(),
  110. OwnerUserId = ownerUserId,
  111. OwnerName = draft.Owner,
  112. DueDate = ParseDate(draft.DueDate),
  113. Status = SmartOpsImprovementActionStatus.Pending,
  114. CompletedAt = null,
  115. Version = 1,
  116. CreateUserId = scope.UserId,
  117. UpdateUserId = scope.UserId,
  118. CreateTime = now,
  119. UpdateTime = now,
  120. });
  121. }
  122. if (rows.Count == 0) return;
  123. await _db.Insertable(rows).ExecuteCommandAsync();
  124. var persisted = await _db.Queryable<AdoSmartOpsImprovementAction>()
  125. .Where(x => x.TenantId == plan.TenantId && x.PlanId == plan.Id)
  126. .OrderBy(x => x.SequenceNo)
  127. .ToListAsync();
  128. foreach (var row in persisted)
  129. await WriteLogAsync(row, "create", null, row, scope.UserId, "从诊断创建");
  130. }
  131. public async Task<AdoSmartOpsImprovementAction> MutateAsync(
  132. long planId,
  133. int? sequenceNo,
  134. long? actionId,
  135. string? status,
  136. string? completedAt,
  137. long? ownerUserId,
  138. string? ownerName,
  139. string? proofRemark,
  140. SmartOpsTrustedScope scope)
  141. {
  142. var plan = await _db.Queryable<AdoSmartOpsImprovementPlan>()
  143. .FirstAsync(x => x.Id == planId && x.TenantId == scope.TenantId)
  144. ?? throw Oops.Oh("改善计划不存在");
  145. await MigrateLegacyJsonAsync(scope.TenantId, plan.Id);
  146. AdoSmartOpsImprovementAction? action = null;
  147. if (actionId is > 0)
  148. {
  149. action = await _db.Queryable<AdoSmartOpsImprovementAction>()
  150. .FirstAsync(x => x.Id == actionId && x.TenantId == scope.TenantId && x.PlanId == plan.Id);
  151. }
  152. else if (sequenceNo is > 0)
  153. {
  154. action = await _db.Queryable<AdoSmartOpsImprovementAction>()
  155. .FirstAsync(x => x.TenantId == scope.TenantId && x.PlanId == plan.Id && x.SequenceNo == sequenceNo);
  156. }
  157. if (action == null) throw Oops.Oh("行动项不存在");
  158. EnsureCanMutate(plan, action, scope);
  159. var before = Snapshot(action);
  160. var previousOwnerUserId = action.OwnerUserId;
  161. var nextStatus = string.IsNullOrWhiteSpace(status)
  162. ? action.Status
  163. : SmartOpsImprovementActionStatus.Normalize(status);
  164. if (!SmartOpsImprovementActionStatus.CanTransition(action.Status, nextStatus))
  165. throw Oops.Oh($"行动项不能从 {action.Status} 变更为 {nextStatus}");
  166. if (ownerUserId.HasValue)
  167. {
  168. if (ownerUserId > 0)
  169. await EnsureOwnerInTenantAsync(scope.TenantId, ownerUserId);
  170. action.OwnerUserId = ownerUserId > 0 ? ownerUserId : null;
  171. }
  172. if (ownerName != null) action.OwnerName = ownerName;
  173. if (proofRemark != null) action.ProofRemark = proofRemark;
  174. var statusChanged = !string.Equals(action.Status, nextStatus, StringComparison.OrdinalIgnoreCase);
  175. var assigned = ownerUserId is > 0 && ownerUserId != previousOwnerUserId;
  176. action.Status = nextStatus;
  177. action.CompletedAt = nextStatus == SmartOpsImprovementActionStatus.Completed
  178. ? ParseDate(completedAt) ?? DateTime.Today
  179. : null;
  180. action.Version += 1;
  181. action.UpdateUserId = scope.UserId;
  182. action.UpdateTime = DateTime.Now;
  183. await _db.Updateable(action)
  184. .Where(x => x.Id == action.Id && x.TenantId == scope.TenantId && x.Version == action.Version - 1)
  185. .ExecuteCommandAsync();
  186. await WriteLogAsync(action, statusChanged ? "status" : "update", before, action, scope.UserId, proofRemark);
  187. if (assigned && (plan.Status == "executing" || plan.Status == "pending_verify"))
  188. await NotifyAsync(plan, action, SmartOpsImprovementNoticeType.Assigned, scope.UserId);
  189. else if (statusChanged)
  190. await NotifyAsync(plan, action, SmartOpsImprovementNoticeType.StatusChanged, scope.UserId);
  191. return action;
  192. }
  193. public async Task<List<AdoSmartOpsImprovementActionLog>> ListLogsAsync(long tenantId, long actionId)
  194. {
  195. return await _db.Queryable<AdoSmartOpsImprovementActionLog>()
  196. .Where(x => x.TenantId == tenantId && x.ActionId == actionId)
  197. .OrderByDescending(x => x.CreateTime)
  198. .ToListAsync();
  199. }
  200. public async Task MigrateLegacyJsonAsync(long tenantId, long? planId = null)
  201. {
  202. var q = _db.Queryable<AdoSmartOpsImprovementPlan>()
  203. .Where(x => x.TenantId == tenantId && !string.IsNullOrWhiteSpace(x.ActionItemsJson));
  204. if (planId is > 0) q = q.Where(x => x.Id == planId);
  205. var plans = await q.ToListAsync();
  206. foreach (var plan in plans)
  207. {
  208. var exists = await _db.Queryable<AdoSmartOpsImprovementAction>()
  209. .AnyAsync(x => x.TenantId == tenantId && x.PlanId == plan.Id);
  210. if (exists) continue;
  211. var drafts = ParseDrafts(plan.ActionItemsJson);
  212. if (drafts.Count == 0) continue;
  213. await ReplaceFromDraftAsync(plan, drafts, new SmartOpsTrustedScope(tenantId, plan.FactoryId, plan.OwnerUserId ?? 0));
  214. }
  215. }
  216. public static List<ImprovementActionItem> ToLegacyItems(IEnumerable<AdoSmartOpsImprovementAction> actions)
  217. {
  218. return actions.Select(x => new ImprovementActionItem
  219. {
  220. Content = x.Content,
  221. Owner = x.OwnerName,
  222. DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
  223. Status = SmartOpsImprovementActionStatus.DisplayStatus(x.Status, x.DueDate, DateTime.Today),
  224. CompletedAt = x.CompletedAt?.ToString("yyyy-MM-dd"),
  225. }).ToList();
  226. }
  227. public static object ToApiItem(AdoSmartOpsImprovementAction x) => new
  228. {
  229. x.Id,
  230. x.SequenceNo,
  231. x.Content,
  232. Owner = x.OwnerName,
  233. x.OwnerUserId,
  234. DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
  235. Status = SmartOpsImprovementActionStatus.DisplayStatus(x.Status, x.DueDate, DateTime.Today),
  236. CompletedAt = x.CompletedAt?.ToString("yyyy-MM-dd"),
  237. x.ProofRemark,
  238. Overdue = SmartOpsImprovementActionStatus.IsOverdue(x.Status, x.DueDate, DateTime.Today),
  239. };
  240. /// <summary>整单到期日:有内容的行动项里,完成日期最晚的一天。</summary>
  241. internal static DateTime? ResolveLatestDueDate(IEnumerable<ImprovementActionItem>? drafts)
  242. {
  243. DateTime? max = null;
  244. if (drafts == null) return null;
  245. foreach (var draft in drafts)
  246. {
  247. if (string.IsNullOrWhiteSpace(draft.Content)) continue;
  248. var parsed = ParseDate(draft.DueDate);
  249. if (parsed == null) continue;
  250. if (max == null || parsed > max) max = parsed;
  251. }
  252. return max;
  253. }
  254. internal static List<ImprovementActionItem> ParseDrafts(string? json)
  255. {
  256. if (string.IsNullOrWhiteSpace(json)) return new List<ImprovementActionItem>();
  257. try
  258. {
  259. return JsonSerializer.Deserialize<List<ImprovementActionItem>>(json, JsonOptions)
  260. ?? new List<ImprovementActionItem>();
  261. }
  262. catch
  263. {
  264. return new List<ImprovementActionItem>();
  265. }
  266. }
  267. private static void EnsureCanMutate(
  268. AdoSmartOpsImprovementPlan plan,
  269. AdoSmartOpsImprovementAction action,
  270. SmartOpsTrustedScope scope)
  271. {
  272. if (scope.UserId > 0 && scope.UserId == action.OwnerUserId) return;
  273. if (scope.UserId > 0 && scope.UserId == action.CreateUserId) return;
  274. if (scope.UserId > 0 && plan.OwnerUserId is > 0 && scope.UserId == plan.OwnerUserId) return;
  275. throw Oops.Oh("无权修改该行动项");
  276. }
  277. private async Task WriteLogAsync(
  278. AdoSmartOpsImprovementAction action,
  279. string operation,
  280. object? before,
  281. object after,
  282. long operatorUserId,
  283. string? remark)
  284. {
  285. await _db.Insertable(new AdoSmartOpsImprovementActionLog
  286. {
  287. TenantId = action.TenantId,
  288. ActionId = action.Id,
  289. PlanId = action.PlanId,
  290. Operation = operation,
  291. BeforeJson = before == null ? null : JsonSerializer.Serialize(before, JsonOptions),
  292. AfterJson = JsonSerializer.Serialize(Snapshot(action), JsonOptions),
  293. OperatorUserId = operatorUserId,
  294. Remark = remark,
  295. CreateTime = DateTime.Now,
  296. }).ExecuteCommandAsync();
  297. }
  298. private async Task NotifyAsync(
  299. AdoSmartOpsImprovementPlan plan,
  300. AdoSmartOpsImprovementAction action,
  301. string noticeType,
  302. long operatorUserId)
  303. {
  304. if (action.OwnerUserId is not > 0) return;
  305. var today = DateTime.Today;
  306. var claimed = await TryClaimNoticeAsync(action.TenantId ?? 0, action.Id, noticeType, today);
  307. if (!claimed) return;
  308. var title = noticeType switch
  309. {
  310. SmartOpsImprovementNoticeType.Assigned => "改善任务已分派",
  311. SmartOpsImprovementNoticeType.DueSoon => "改善任务即将到期",
  312. SmartOpsImprovementNoticeType.Overdue => "改善任务已逾期",
  313. _ => "改善任务状态变更",
  314. };
  315. var due = action.DueDate?.ToString("yyyy-MM-dd") ?? "-";
  316. var jump = $"/#/aidop/smart-ops/improvement-plans?id={plan.Id}&actionId={action.Id}";
  317. var content =
  318. $"<p>计划 {plan.PlanNo} 行动项#{action.SequenceNo}:{action.Content}。到期日 {due}。</p>" +
  319. $"<p><a href=\"{jump}\">查看任务详情</a></p>";
  320. long noticeId = 0;
  321. try
  322. {
  323. noticeId = await _noticeService.PublishToUsersAsync(
  324. title,
  325. content,
  326. new[] { action.OwnerUserId.Value },
  327. operatorUserId > 0 ? operatorUserId : action.OwnerUserId.Value,
  328. "智慧诊断");
  329. }
  330. catch (Exception ex)
  331. {
  332. _logger.LogWarning(ex, "改善行动项通知发布失败 actionId={ActionId} type={Type}", action.Id, noticeType);
  333. }
  334. if (noticeId > 0)
  335. {
  336. await _db.Updateable<AdoSmartOpsImprovementActionNotice>()
  337. .SetColumns(x => x.NoticeId == noticeId)
  338. .Where(x => x.TenantId == action.TenantId && x.ActionId == action.Id
  339. && x.NoticeType == noticeType && x.NoticeDate == today)
  340. .ExecuteCommandAsync();
  341. }
  342. }
  343. public async Task<int> SendDueRemindersAsync(ISqlSugarClient db, long tenantId, DateTime today)
  344. {
  345. var actions = await db.Queryable<AdoSmartOpsImprovementAction>()
  346. .Where(x => x.TenantId == tenantId && x.OwnerUserId > 0 && x.DueDate != null)
  347. .Where(x => x.Status == SmartOpsImprovementActionStatus.Pending
  348. || x.Status == SmartOpsImprovementActionStatus.InProgress)
  349. .ToListAsync();
  350. var sent = 0;
  351. foreach (var action in actions)
  352. {
  353. var plan = await db.Queryable<AdoSmartOpsImprovementPlan>()
  354. .FirstAsync(x => x.Id == action.PlanId && x.TenantId == tenantId);
  355. if (plan == null) continue;
  356. var due = action.DueDate!.Value.Date;
  357. string? type = null;
  358. if (due < today) type = SmartOpsImprovementNoticeType.Overdue;
  359. else if (due <= today.AddDays(1)) type = SmartOpsImprovementNoticeType.DueSoon;
  360. if (type == null) continue;
  361. try
  362. {
  363. await NotifyAsync(plan, action, type, action.OwnerUserId ?? 0);
  364. sent++;
  365. }
  366. catch
  367. {
  368. // Keep scanning remaining tenants/actions.
  369. }
  370. }
  371. return sent;
  372. }
  373. private async Task<bool> TryClaimNoticeAsync(long tenantId, long actionId, string noticeType, DateTime date)
  374. {
  375. try
  376. {
  377. await _db.Insertable(new AdoSmartOpsImprovementActionNotice
  378. {
  379. TenantId = tenantId,
  380. ActionId = actionId,
  381. NoticeType = noticeType,
  382. NoticeDate = date.Date,
  383. CreateTime = DateTime.Now,
  384. }).ExecuteCommandAsync();
  385. return true;
  386. }
  387. catch (Exception ex) when (ex.Message.Contains("Duplicate", StringComparison.OrdinalIgnoreCase)
  388. || ex.InnerException?.Message.Contains("Duplicate", StringComparison.OrdinalIgnoreCase) == true)
  389. {
  390. return false;
  391. }
  392. }
  393. private static object Snapshot(AdoSmartOpsImprovementAction x) => new
  394. {
  395. x.Id,
  396. x.SequenceNo,
  397. x.Content,
  398. Owner = x.OwnerName,
  399. x.OwnerUserId,
  400. x.Status,
  401. DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
  402. CompletedAt = x.CompletedAt?.ToString("yyyy-MM-dd"),
  403. x.Version,
  404. };
  405. private async Task EnsureOwnerInTenantAsync(long tenantId, long? ownerUserId)
  406. {
  407. if (ownerUserId is not > 0 || tenantId <= 0) return;
  408. var ok = await _db.Queryable<SysUser>().ClearFilter<ITenantIdFilter>()
  409. .AnyAsync(x => x.Id == ownerUserId && x.TenantId == tenantId);
  410. if (!ok)
  411. throw Oops.Oh("责任人不是当前租户的有效用户");
  412. }
  413. private static DateTime? ParseDate(string? raw) =>
  414. DateTime.TryParse(raw, out var value) ? value.Date : null;
  415. }