SmartOpsImprovementActionService.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. using System.Text.Json;
  2. using Admin.NET.Core;
  3. using Admin.NET.Plugin.AiDOP.Controllers;
  4. using Admin.NET.Plugin.AiDOP.Entity;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure;
  6. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  7. public sealed class SmartOpsImprovementActionService : ITransient
  8. {
  9. private static readonly JsonSerializerOptions JsonOptions = new()
  10. {
  11. PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
  12. PropertyNameCaseInsensitive = true,
  13. };
  14. private readonly ISqlSugarClient _db;
  15. public SmartOpsImprovementActionService(ISqlSugarClient db) => _db = db;
  16. public async Task<List<AdoSmartOpsImprovementAction>> ListByPlanAsync(long tenantId, long planId)
  17. {
  18. await MigrateLegacyJsonAsync(tenantId, planId);
  19. return await _db.Queryable<AdoSmartOpsImprovementAction>()
  20. .Where(x => x.TenantId == tenantId && x.PlanId == planId)
  21. .OrderBy(x => x.SequenceNo)
  22. .ToListAsync();
  23. }
  24. public async Task<List<AdoSmartOpsImprovementAction>> ListMineAsync(SmartOpsTrustedScope scope)
  25. {
  26. return await _db.Queryable<AdoSmartOpsImprovementAction>()
  27. .Where(x => x.TenantId == scope.TenantId && x.OwnerUserId == scope.UserId)
  28. .OrderBy(x => x.DueDate)
  29. .ToListAsync();
  30. }
  31. public async Task ReplaceFromDraftAsync(
  32. AdoSmartOpsImprovementPlan plan,
  33. IReadOnlyList<ImprovementActionItem> drafts,
  34. SmartOpsTrustedScope scope)
  35. {
  36. var existing = await _db.Queryable<AdoSmartOpsImprovementAction>()
  37. .Where(x => x.TenantId == plan.TenantId && x.PlanId == plan.Id)
  38. .ToListAsync();
  39. if (existing.Count > 0)
  40. return;
  41. var now = DateTime.Now;
  42. var rows = new List<AdoSmartOpsImprovementAction>();
  43. for (var i = 0; i < drafts.Count; i++)
  44. {
  45. var draft = drafts[i];
  46. if (string.IsNullOrWhiteSpace(draft.Content)) continue;
  47. var ownerUserId = ParseOwnerUserId(draft.Owner);
  48. rows.Add(new AdoSmartOpsImprovementAction
  49. {
  50. TenantId = plan.TenantId,
  51. FactoryId = plan.FactoryId,
  52. PlanId = plan.Id,
  53. SequenceNo = rows.Count + 1,
  54. Content = draft.Content.Trim(),
  55. OwnerUserId = ownerUserId,
  56. OwnerName = draft.Owner,
  57. DueDate = ParseDate(draft.DueDate),
  58. Status = SmartOpsImprovementActionStatus.Normalize(draft.Status),
  59. CompletedAt = ParseDate(draft.CompletedAt),
  60. Version = 1,
  61. CreateUserId = scope.UserId,
  62. UpdateUserId = scope.UserId,
  63. CreateTime = now,
  64. UpdateTime = now,
  65. });
  66. }
  67. if (rows.Count == 0) return;
  68. await _db.Insertable(rows).ExecuteCommandAsync();
  69. foreach (var row in rows)
  70. {
  71. await WriteLogAsync(row, "create", null, row, scope.UserId, "从诊断创建");
  72. if (row.OwnerUserId is > 0)
  73. await NotifyAsync(plan, row, SmartOpsImprovementNoticeType.Assigned, scope.UserId);
  74. }
  75. }
  76. public async Task<AdoSmartOpsImprovementAction> MutateAsync(
  77. long planId,
  78. int? sequenceNo,
  79. long? actionId,
  80. string? status,
  81. string? completedAt,
  82. long? ownerUserId,
  83. string? ownerName,
  84. string? proofRemark,
  85. SmartOpsTrustedScope scope)
  86. {
  87. var plan = await _db.Queryable<AdoSmartOpsImprovementPlan>()
  88. .FirstAsync(x => x.Id == planId && x.TenantId == scope.TenantId)
  89. ?? throw Oops.Oh("改善计划不存在");
  90. await MigrateLegacyJsonAsync(scope.TenantId, plan.Id);
  91. AdoSmartOpsImprovementAction? action = null;
  92. if (actionId is > 0)
  93. {
  94. action = await _db.Queryable<AdoSmartOpsImprovementAction>()
  95. .FirstAsync(x => x.Id == actionId && x.TenantId == scope.TenantId && x.PlanId == plan.Id);
  96. }
  97. else if (sequenceNo is > 0)
  98. {
  99. action = await _db.Queryable<AdoSmartOpsImprovementAction>()
  100. .FirstAsync(x => x.TenantId == scope.TenantId && x.PlanId == plan.Id && x.SequenceNo == sequenceNo);
  101. }
  102. if (action == null) throw Oops.Oh("行动项不存在");
  103. EnsureCanMutate(plan, action, scope);
  104. var before = Snapshot(action);
  105. var previousOwnerUserId = action.OwnerUserId;
  106. var nextStatus = string.IsNullOrWhiteSpace(status)
  107. ? action.Status
  108. : SmartOpsImprovementActionStatus.Normalize(status);
  109. if (!SmartOpsImprovementActionStatus.CanTransition(action.Status, nextStatus))
  110. throw Oops.Oh($"行动项不能从 {action.Status} 变更为 {nextStatus}");
  111. if (ownerUserId.HasValue) action.OwnerUserId = ownerUserId;
  112. if (ownerName != null) action.OwnerName = ownerName;
  113. if (proofRemark != null) action.ProofRemark = proofRemark;
  114. var statusChanged = !string.Equals(action.Status, nextStatus, StringComparison.OrdinalIgnoreCase);
  115. var assigned = ownerUserId is > 0 && ownerUserId != previousOwnerUserId;
  116. action.Status = nextStatus;
  117. action.CompletedAt = nextStatus == SmartOpsImprovementActionStatus.Completed
  118. ? ParseDate(completedAt) ?? DateTime.Today
  119. : null;
  120. action.Version += 1;
  121. action.UpdateUserId = scope.UserId;
  122. action.UpdateTime = DateTime.Now;
  123. await _db.Updateable(action)
  124. .Where(x => x.Id == action.Id && x.TenantId == scope.TenantId && x.Version == action.Version - 1)
  125. .ExecuteCommandAsync();
  126. await WriteLogAsync(action, statusChanged ? "status" : "update", before, action, scope.UserId, proofRemark);
  127. if (assigned)
  128. await NotifyAsync(plan, action, SmartOpsImprovementNoticeType.Assigned, scope.UserId);
  129. else if (statusChanged)
  130. await NotifyAsync(plan, action, SmartOpsImprovementNoticeType.StatusChanged, scope.UserId);
  131. return action;
  132. }
  133. public async Task<List<AdoSmartOpsImprovementActionLog>> ListLogsAsync(long tenantId, long actionId)
  134. {
  135. return await _db.Queryable<AdoSmartOpsImprovementActionLog>()
  136. .Where(x => x.TenantId == tenantId && x.ActionId == actionId)
  137. .OrderByDescending(x => x.CreateTime)
  138. .ToListAsync();
  139. }
  140. public async Task MigrateLegacyJsonAsync(long tenantId, long? planId = null)
  141. {
  142. var q = _db.Queryable<AdoSmartOpsImprovementPlan>()
  143. .Where(x => x.TenantId == tenantId && !string.IsNullOrWhiteSpace(x.ActionItemsJson));
  144. if (planId is > 0) q = q.Where(x => x.Id == planId);
  145. var plans = await q.ToListAsync();
  146. foreach (var plan in plans)
  147. {
  148. var exists = await _db.Queryable<AdoSmartOpsImprovementAction>()
  149. .AnyAsync(x => x.TenantId == tenantId && x.PlanId == plan.Id);
  150. if (exists) continue;
  151. var drafts = ParseDrafts(plan.ActionItemsJson);
  152. if (drafts.Count == 0) continue;
  153. await ReplaceFromDraftAsync(plan, drafts, new SmartOpsTrustedScope(tenantId, plan.FactoryId, plan.OwnerUserId ?? 0));
  154. }
  155. }
  156. public static List<ImprovementActionItem> ToLegacyItems(IEnumerable<AdoSmartOpsImprovementAction> actions)
  157. {
  158. return actions.Select(x => new ImprovementActionItem
  159. {
  160. Content = x.Content,
  161. Owner = x.OwnerName,
  162. DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
  163. Status = SmartOpsImprovementActionStatus.DisplayStatus(x.Status, x.DueDate, DateTime.Today),
  164. CompletedAt = x.CompletedAt?.ToString("yyyy-MM-dd"),
  165. }).ToList();
  166. }
  167. public static object ToApiItem(AdoSmartOpsImprovementAction x) => new
  168. {
  169. x.Id,
  170. x.SequenceNo,
  171. x.Content,
  172. Owner = x.OwnerName,
  173. x.OwnerUserId,
  174. DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
  175. Status = SmartOpsImprovementActionStatus.DisplayStatus(x.Status, x.DueDate, DateTime.Today),
  176. CompletedAt = x.CompletedAt?.ToString("yyyy-MM-dd"),
  177. x.ProofRemark,
  178. Overdue = SmartOpsImprovementActionStatus.IsOverdue(x.Status, x.DueDate, DateTime.Today),
  179. };
  180. internal static List<ImprovementActionItem> ParseDrafts(string? json)
  181. {
  182. if (string.IsNullOrWhiteSpace(json)) return new List<ImprovementActionItem>();
  183. try
  184. {
  185. return JsonSerializer.Deserialize<List<ImprovementActionItem>>(json, JsonOptions)
  186. ?? new List<ImprovementActionItem>();
  187. }
  188. catch
  189. {
  190. return new List<ImprovementActionItem>();
  191. }
  192. }
  193. private static void EnsureCanMutate(
  194. AdoSmartOpsImprovementPlan plan,
  195. AdoSmartOpsImprovementAction action,
  196. SmartOpsTrustedScope scope)
  197. {
  198. if (scope.UserId > 0 && scope.UserId == action.OwnerUserId) return;
  199. if (scope.UserId > 0 && scope.UserId == action.CreateUserId) return;
  200. if (scope.UserId > 0 && plan.OwnerUserId is > 0 && scope.UserId == plan.OwnerUserId) return;
  201. throw Oops.Oh("无权修改该行动项");
  202. }
  203. private async Task WriteLogAsync(
  204. AdoSmartOpsImprovementAction action,
  205. string operation,
  206. object? before,
  207. object after,
  208. long operatorUserId,
  209. string? remark)
  210. {
  211. await _db.Insertable(new AdoSmartOpsImprovementActionLog
  212. {
  213. TenantId = action.TenantId,
  214. ActionId = action.Id,
  215. PlanId = action.PlanId,
  216. Operation = operation,
  217. BeforeJson = before == null ? null : JsonSerializer.Serialize(before, JsonOptions),
  218. AfterJson = JsonSerializer.Serialize(Snapshot(action), JsonOptions),
  219. OperatorUserId = operatorUserId,
  220. Remark = remark,
  221. CreateTime = DateTime.Now,
  222. }).ExecuteCommandAsync();
  223. }
  224. private async Task NotifyAsync(
  225. AdoSmartOpsImprovementPlan plan,
  226. AdoSmartOpsImprovementAction action,
  227. string noticeType,
  228. long operatorUserId)
  229. {
  230. if (action.OwnerUserId is not > 0) return;
  231. var today = DateTime.Today;
  232. var claimed = await TryClaimNoticeAsync(action.TenantId ?? 0, action.Id, noticeType, today);
  233. if (!claimed) return;
  234. var title = noticeType switch
  235. {
  236. SmartOpsImprovementNoticeType.Assigned => "改善任务已分派",
  237. SmartOpsImprovementNoticeType.DueSoon => "改善任务即将到期",
  238. SmartOpsImprovementNoticeType.Overdue => "改善任务已逾期",
  239. _ => "改善任务状态变更",
  240. };
  241. var due = action.DueDate?.ToString("yyyy-MM-dd") ?? "-";
  242. var content =
  243. $"计划 {plan.PlanNo} 行动项#{action.SequenceNo}:{action.Content}。到期日 {due}。详情 /aidop/smart-diagnosis";
  244. var now = DateTime.Now;
  245. var notice = new SysNotice
  246. {
  247. Title = title,
  248. Content = content,
  249. Type = NoticeTypeEnum.NOTICE,
  250. PublicUserId = operatorUserId > 0 ? operatorUserId : action.OwnerUserId.Value,
  251. PublicUserName = "智慧诊断",
  252. PublicTime = now,
  253. Status = NoticeStatusEnum.PUBLIC,
  254. CreateTime = now,
  255. CreateUserId = operatorUserId > 0 ? operatorUserId : action.OwnerUserId,
  256. CreateUserName = "智慧诊断",
  257. };
  258. var noticeId = await _db.Insertable(notice).ExecuteReturnSnowflakeIdAsync();
  259. await _db.Insertable(new SysNoticeUser
  260. {
  261. NoticeId = noticeId,
  262. UserId = action.OwnerUserId.Value,
  263. ReadStatus = NoticeUserStatusEnum.UNREAD,
  264. }).ExecuteCommandAsync();
  265. await _db.Updateable<AdoSmartOpsImprovementActionNotice>()
  266. .SetColumns(x => x.NoticeId == noticeId)
  267. .Where(x => x.TenantId == action.TenantId && x.ActionId == action.Id
  268. && x.NoticeType == noticeType && x.NoticeDate == today)
  269. .ExecuteCommandAsync();
  270. }
  271. public async Task<int> SendDueRemindersAsync(ISqlSugarClient db, long tenantId, DateTime today)
  272. {
  273. var actions = await db.Queryable<AdoSmartOpsImprovementAction>()
  274. .Where(x => x.TenantId == tenantId && x.OwnerUserId > 0 && x.DueDate != null)
  275. .Where(x => x.Status == SmartOpsImprovementActionStatus.Pending
  276. || x.Status == SmartOpsImprovementActionStatus.InProgress)
  277. .ToListAsync();
  278. var sent = 0;
  279. foreach (var action in actions)
  280. {
  281. var plan = await db.Queryable<AdoSmartOpsImprovementPlan>()
  282. .FirstAsync(x => x.Id == action.PlanId && x.TenantId == tenantId);
  283. if (plan == null) continue;
  284. var due = action.DueDate!.Value.Date;
  285. string? type = null;
  286. if (due < today) type = SmartOpsImprovementNoticeType.Overdue;
  287. else if (due <= today.AddDays(1)) type = SmartOpsImprovementNoticeType.DueSoon;
  288. if (type == null) continue;
  289. try
  290. {
  291. var scoped = new SmartOpsImprovementActionService(db);
  292. await scoped.NotifyAsync(plan, action, type, action.OwnerUserId ?? 0);
  293. sent++;
  294. }
  295. catch
  296. {
  297. // Keep scanning remaining tenants/actions.
  298. }
  299. }
  300. return sent;
  301. }
  302. private async Task<bool> TryClaimNoticeAsync(long tenantId, long actionId, string noticeType, DateTime date)
  303. {
  304. try
  305. {
  306. await _db.Insertable(new AdoSmartOpsImprovementActionNotice
  307. {
  308. TenantId = tenantId,
  309. ActionId = actionId,
  310. NoticeType = noticeType,
  311. NoticeDate = date.Date,
  312. CreateTime = DateTime.Now,
  313. }).ExecuteCommandAsync();
  314. return true;
  315. }
  316. catch (Exception ex) when (ex.Message.Contains("Duplicate", StringComparison.OrdinalIgnoreCase)
  317. || ex.InnerException?.Message.Contains("Duplicate", StringComparison.OrdinalIgnoreCase) == true)
  318. {
  319. return false;
  320. }
  321. }
  322. private static object Snapshot(AdoSmartOpsImprovementAction x) => new
  323. {
  324. x.Id,
  325. x.SequenceNo,
  326. x.Content,
  327. Owner = x.OwnerName,
  328. x.OwnerUserId,
  329. x.Status,
  330. DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
  331. CompletedAt = x.CompletedAt?.ToString("yyyy-MM-dd"),
  332. x.Version,
  333. };
  334. private static DateTime? ParseDate(string? raw) =>
  335. DateTime.TryParse(raw, out var value) ? value.Date : null;
  336. private static long? ParseOwnerUserId(string? raw) =>
  337. long.TryParse(raw, out var value) && value > 0 ? value : null;
  338. }