AdoSmartOpsImprovementPlanController.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. using System.Globalization;
  2. using System.Text.Json;
  3. using Admin.NET.Plugin.AiDOP.Entity;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Admin.NET.Plugin.AiDOP.Service;
  6. using Admin.NET.Plugin.ApprovalFlow.Service;
  7. namespace Admin.NET.Plugin.AiDOP.Controllers;
  8. [ApiController]
  9. [Route("api/[controller]")]
  10. [AllowAnonymous]
  11. [NonUnify]
  12. public class AdoSmartOpsImprovementPlanController : ControllerBase
  13. {
  14. private readonly ISqlSugarClient _db;
  15. private readonly FlowEngineService _flowEngine;
  16. public AdoSmartOpsImprovementPlanController(ISqlSugarClient db, FlowEngineService flowEngine)
  17. {
  18. _db = db;
  19. _flowEngine = flowEngine;
  20. }
  21. [HttpPost("createFromDiagnosis")]
  22. public async Task<IActionResult> CreateFromDiagnosis([FromBody] ImprovementPlanCreateInput input)
  23. {
  24. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  25. var now = DateTime.Now;
  26. var entity = new AdoSmartOpsImprovementPlan
  27. {
  28. TenantId = tenantId,
  29. FactoryId = input.FactoryId <= 0 ? 1 : input.FactoryId,
  30. PlanNo = await GeneratePlanNoAsync(),
  31. ModuleCode = (input.ModuleCode ?? "").Trim().ToUpperInvariant(),
  32. MetricCode = input.MetricCode,
  33. ProblemLevel = input.ProblemLevel,
  34. ProblemMetricCode = input.ProblemMetricCode,
  35. ProblemName = input.ProblemName ?? "",
  36. ProblemDept = input.ProblemDept,
  37. TargetValue = input.TargetValue,
  38. ActualValue = input.ActualValue,
  39. GapLabel = input.GapLabel,
  40. RootCause = input.RootCause,
  41. ActionItemsJson = JsonSerializer.Serialize(input.ActionItems ?? new List<ImprovementActionItem>()),
  42. OwnerUserId = input.OwnerUserId,
  43. DueDate = input.DueDate,
  44. Status = "draft",
  45. CreateTime = now,
  46. UpdateTime = now,
  47. };
  48. if (string.IsNullOrWhiteSpace(entity.ModuleCode)) return BadRequest(new { message = "模块编码不能为空" });
  49. if (string.IsNullOrWhiteSpace(entity.ProblemName)) return BadRequest(new { message = "问题名称不能为空" });
  50. entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
  51. return Ok(ToOutput(entity));
  52. }
  53. [HttpPost("page")]
  54. public async Task<IActionResult> Page([FromBody] ImprovementPlanPageInput input)
  55. {
  56. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  57. var page = input.Page <= 0 ? 1 : input.Page;
  58. var pageSize = input.PageSize <= 0 ? 20 : input.PageSize;
  59. var q = _db.Queryable<AdoSmartOpsImprovementPlan>()
  60. .Where(x => x.TenantId == tenantId)
  61. .WhereIF(!string.IsNullOrWhiteSpace(input.ModuleCode), x => x.ModuleCode == input.ModuleCode)
  62. .WhereIF(!string.IsNullOrWhiteSpace(input.Status), x => x.Status == input.Status)
  63. .WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), x => x.PlanNo.Contains(input.Keyword!) || x.ProblemName.Contains(input.Keyword!))
  64. .OrderByDescending(x => x.CreateTime);
  65. var result = await q.ToPagedListAsync(page, pageSize);
  66. return Ok(new
  67. {
  68. result.Page,
  69. result.PageSize,
  70. result.Total,
  71. Items = result.Items.Select(ToOutput).ToList()
  72. });
  73. }
  74. [HttpGet("detail")]
  75. public async Task<IActionResult> Detail([FromQuery] long id)
  76. {
  77. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  78. var entity = await _db.Queryable<AdoSmartOpsImprovementPlan>()
  79. .FirstAsync(x => x.Id == id && x.TenantId == tenantId);
  80. return entity == null ? NotFound() : Ok(ToOutput(entity));
  81. }
  82. [HttpGet("verificationDashboard")]
  83. public async Task<IActionResult> VerificationDashboard([FromQuery] long id)
  84. {
  85. var plan = await LoadPlanAsync(id) ?? throw Oops.Oh("改善计划不存在");
  86. var actions = ParseActionItems(plan.ActionItemsJson);
  87. var startDate = plan.CreateTime.Date;
  88. var endDate = ResolveVerificationEndDate(plan, actions);
  89. if (endDate < startDate) endDate = startDate;
  90. var metricCode = (plan.ProblemMetricCode ?? plan.MetricCode ?? "").Trim();
  91. var metricMeta = string.IsNullOrWhiteSpace(metricCode)
  92. ? null
  93. : await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.TenantId == plan.TenantId && x.MetricCode == metricCode);
  94. var metricLevel = metricMeta?.MetricLevel ?? NormalizeMetricLevel(plan.ProblemLevel);
  95. var targetFallback = ParseDecimal(plan.TargetValue);
  96. var table = ResolveKpiValueTable(metricLevel);
  97. var trendRows = await QueryVerificationTrendAsync(table, plan, metricCode, startDate, endDate);
  98. if (trendRows.Count == 0)
  99. {
  100. foreach (var fallbackTable in new[] { "ado_s9_kpi_value_l4_day", "ado_s9_kpi_value_l3_day", "ado_s9_kpi_value_l2_day", "ado_s9_kpi_value_l1_day" })
  101. {
  102. if (fallbackTable == table) continue;
  103. trendRows = await QueryVerificationTrendAsync(fallbackTable, plan, metricCode, startDate, endDate);
  104. if (trendRows.Count > 0) break;
  105. }
  106. }
  107. var trendByDate = trendRows
  108. .GroupBy(x => x.BizDate.Date)
  109. .ToDictionary(x => x.Key, x => x.OrderByDescending(r => r.TargetValue.HasValue).First());
  110. var timeline = BuildDateRange(startDate, endDate)
  111. .Select(day => new
  112. {
  113. Date = day.ToString("yyyy-MM-dd"),
  114. IsStart = day == startDate,
  115. IsEnd = day == endDate,
  116. Actions = actions
  117. .Select((action, index) => BuildVerificationActionNode(action, index, day))
  118. .Where(x => x != null)
  119. .ToList(),
  120. })
  121. .ToList();
  122. var series = BuildDateRange(startDate, endDate)
  123. .Select(day =>
  124. {
  125. trendByDate.TryGetValue(day, out var row);
  126. return new
  127. {
  128. Date = day.ToString("yyyy-MM-dd"),
  129. Value = row?.MetricValue,
  130. Target = row?.TargetValue ?? targetFallback,
  131. };
  132. })
  133. .ToList();
  134. return Ok(new
  135. {
  136. Plan = ToOutput(plan),
  137. Metric = new
  138. {
  139. MetricCode = metricCode,
  140. MetricName = metricMeta?.MetricName ?? plan.ProblemName,
  141. Unit = metricMeta?.Unit ?? ResolveUnitFromText(plan.TargetValue),
  142. TargetValue = targetFallback,
  143. Level = metricLevel,
  144. },
  145. StartDate = startDate.ToString("yyyy-MM-dd"),
  146. EndDate = endDate.ToString("yyyy-MM-dd"),
  147. Timeline = timeline,
  148. Trend = series,
  149. });
  150. }
  151. [HttpPost("submitApproval")]
  152. public async Task<IActionResult> SubmitApproval([FromBody] ImprovementPlanIdInput input)
  153. {
  154. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  155. if (plan.Status is "approving" or "executing" or "pending_verify" or "closed")
  156. return BadRequest(new { message = "当前状态不可提交审批" });
  157. try
  158. {
  159. var instanceId = await _flowEngine.StartFlow(new StartFlowInput
  160. {
  161. BizType = SmartOpsImprovementBizHandler.BizTypeCode,
  162. BizId = plan.Id,
  163. BizNo = plan.PlanNo,
  164. Title = $"智慧诊断改善计划-{plan.PlanNo}",
  165. Comment = input.Comment,
  166. BizData = new Dictionary<string, object>
  167. {
  168. ["moduleCode"] = plan.ModuleCode,
  169. ["problemLevel"] = plan.ProblemLevel,
  170. ["problemName"] = plan.ProblemName,
  171. ["problemDept"] = plan.ProblemDept ?? "",
  172. }
  173. });
  174. plan = await LoadPlanAsync(input.Id) ?? plan;
  175. return Ok(new { instanceId, plan = ToOutput(plan) });
  176. }
  177. catch (Exception ex)
  178. {
  179. return BadRequest(new { message = $"提交审批失败:{ex.Message}" });
  180. }
  181. }
  182. [HttpPost("startExecution")]
  183. public async Task<IActionResult> StartExecution([FromBody] ImprovementPlanIdInput input)
  184. {
  185. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  186. if (plan.Status != "approved" && plan.Status != "executing")
  187. return BadRequest(new { message = "审批通过后才能开始执行" });
  188. await UpdateStatusAsync(plan.Id, "executing");
  189. return Ok(ToOutput((await LoadPlanAsync(plan.Id))!));
  190. }
  191. [HttpPost("updateActionItem")]
  192. public async Task<IActionResult> UpdateActionItem([FromBody] ImprovementActionItemUpdateInput input)
  193. {
  194. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  195. if (input.Index <= 0) return BadRequest(new { message = "行动项序号无效" });
  196. var actions = ParseActionItems(plan.ActionItemsJson);
  197. var index = input.Index - 1;
  198. if (index < 0 || index >= actions.Count) return BadRequest(new { message = "行动项不存在" });
  199. var status = NormalizeManualActionStatus(input.Status);
  200. actions[index].Status = status;
  201. actions[index].CompletedAt = ResolveActionCompletedAt(status, input.CompletedAt);
  202. await _db.Updateable<AdoSmartOpsImprovementPlan>()
  203. .SetColumns(x => x.ActionItemsJson == JsonSerializer.Serialize(actions))
  204. .SetColumns(x => x.UpdateTime == DateTime.Now)
  205. .Where(x => x.Id == plan.Id)
  206. .ExecuteCommandAsync();
  207. return Ok(ToOutput((await LoadPlanAsync(plan.Id))!));
  208. }
  209. [HttpPost("submitVerification")]
  210. public async Task<IActionResult> SubmitVerification([FromBody] ImprovementVerificationInput input)
  211. {
  212. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  213. if (plan.Status is not ("executing" or "pending_verify"))
  214. return BadRequest(new { message = "执行中计划才能提交验证" });
  215. await _db.Updateable<AdoSmartOpsImprovementPlan>()
  216. .SetColumns(x => x.Status == "pending_verify")
  217. .SetColumns(x => x.VerifyResult == input.VerifyResult)
  218. .SetColumns(x => x.VerifyValue == input.VerifyValue)
  219. .SetColumns(x => x.VerifyRemark == input.VerifyRemark)
  220. .SetColumns(x => x.UpdateTime == DateTime.Now)
  221. .Where(x => x.Id == plan.Id)
  222. .ExecuteCommandAsync();
  223. return Ok(ToOutput((await LoadPlanAsync(plan.Id))!));
  224. }
  225. [HttpPost("close")]
  226. public async Task<IActionResult> Close([FromBody] ImprovementPlanIdInput input)
  227. {
  228. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  229. if (plan.Status != "pending_verify")
  230. return BadRequest(new { message = "提交验证后才能关闭" });
  231. await UpdateStatusAsync(plan.Id, "closed");
  232. return Ok(ToOutput((await LoadPlanAsync(plan.Id))!));
  233. }
  234. private async Task<AdoSmartOpsImprovementPlan?> LoadPlanAsync(long id)
  235. {
  236. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  237. return await _db.Queryable<AdoSmartOpsImprovementPlan>().FirstAsync(x => x.Id == id && x.TenantId == tenantId);
  238. }
  239. private async Task UpdateStatusAsync(long id, string status)
  240. {
  241. await _db.Updateable<AdoSmartOpsImprovementPlan>()
  242. .SetColumns(x => x.Status == status)
  243. .SetColumns(x => x.UpdateTime == DateTime.Now)
  244. .Where(x => x.Id == id)
  245. .ExecuteCommandAsync();
  246. }
  247. private async Task<string> GeneratePlanNoAsync()
  248. {
  249. var prefix = $"SOP-{DateTime.Now:yyyyMMdd}";
  250. var count = await _db.Queryable<AdoSmartOpsImprovementPlan>()
  251. .Where(x => x.PlanNo.StartsWith(prefix))
  252. .CountAsync();
  253. return $"{prefix}-{count + 1:000}";
  254. }
  255. private static object ToOutput(AdoSmartOpsImprovementPlan x)
  256. {
  257. return new
  258. {
  259. x.Id,
  260. x.PlanNo,
  261. x.ModuleCode,
  262. x.MetricCode,
  263. x.ProblemLevel,
  264. x.ProblemMetricCode,
  265. x.ProblemName,
  266. x.ProblemDept,
  267. x.TargetValue,
  268. x.ActualValue,
  269. x.GapLabel,
  270. x.RootCause,
  271. ActionItems = ParseActionItems(x.ActionItemsJson),
  272. x.OwnerUserId,
  273. DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
  274. x.Status,
  275. x.FlowInstanceId,
  276. x.VerifyResult,
  277. x.VerifyValue,
  278. x.VerifyRemark,
  279. x.CreateTime,
  280. x.UpdateTime,
  281. };
  282. }
  283. private async Task<List<ImprovementVerificationTrendRow>> QueryVerificationTrendAsync(
  284. string table,
  285. AdoSmartOpsImprovementPlan plan,
  286. string metricCode,
  287. DateTime startDate,
  288. DateTime endDate)
  289. {
  290. if (string.IsNullOrWhiteSpace(metricCode)) return new List<ImprovementVerificationTrendRow>();
  291. var sql = $"""
  292. SELECT biz_date AS BizDate, metric_value AS MetricValue, target_value AS TargetValue
  293. FROM {table}
  294. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode
  295. AND metric_code=@metricCode AND is_deleted=0 AND biz_date BETWEEN @startDate AND @endDate
  296. ORDER BY biz_date
  297. """;
  298. return await _db.Ado.SqlQueryAsync<ImprovementVerificationTrendRow>(sql, new
  299. {
  300. tenantId = plan.TenantId,
  301. factoryId = plan.FactoryId <= 0 ? 1 : plan.FactoryId,
  302. moduleCode = plan.ModuleCode,
  303. metricCode,
  304. startDate,
  305. endDate,
  306. });
  307. }
  308. private static DateTime ResolveVerificationEndDate(AdoSmartOpsImprovementPlan plan, List<ImprovementActionItem> actions)
  309. {
  310. var candidates = new List<DateTime> { plan.CreateTime.Date };
  311. if (plan.DueDate.HasValue) candidates.Add(plan.DueDate.Value.Date);
  312. foreach (var action in actions)
  313. {
  314. if (DateTime.TryParse(action.DueDate, out var dueDate)) candidates.Add(dueDate.Date);
  315. if (DateTime.TryParse(action.CompletedAt, out var completedAt)) candidates.Add(completedAt.Date);
  316. }
  317. return candidates.Max();
  318. }
  319. private static IEnumerable<DateTime> BuildDateRange(DateTime startDate, DateTime endDate)
  320. {
  321. for (var day = startDate.Date; day <= endDate.Date; day = day.AddDays(1))
  322. yield return day;
  323. }
  324. private static ImprovementVerificationActionNode? BuildVerificationActionNode(ImprovementActionItem action, int index, DateTime day)
  325. {
  326. if (!DateTime.TryParse(action.DueDate, out var dueDate) || dueDate.Date != day.Date) return null;
  327. var status = NormalizeActionStatus(action.Status, action.CompletedAt, dueDate.Date);
  328. return new ImprovementVerificationActionNode
  329. {
  330. Index = index + 1,
  331. Content = action.Content,
  332. Owner = action.Owner,
  333. DueDate = dueDate.ToString("yyyy-MM-dd"),
  334. Status = status,
  335. StatusLabel = ActionStatusLabel(status),
  336. CompletedAt = DateTime.TryParse(action.CompletedAt, out var completedAt) ? completedAt.ToString("yyyy-MM-dd") : null,
  337. };
  338. }
  339. private static string NormalizeActionStatus(string? status, string? completedAt, DateTime dueDate)
  340. {
  341. var normalized = (status ?? "").Trim().ToLowerInvariant();
  342. if (normalized is "completed" or "done") return "completed";
  343. if (normalized is "doing" or "in_progress") return "doing";
  344. if (!string.IsNullOrWhiteSpace(completedAt)) return "completed";
  345. if (dueDate < DateTime.Today) return "overdue";
  346. return string.IsNullOrWhiteSpace(normalized) ? "pending" : normalized;
  347. }
  348. private static string NormalizeManualActionStatus(string? status)
  349. {
  350. var normalized = (status ?? "").Trim().ToLowerInvariant();
  351. return normalized switch
  352. {
  353. "completed" or "done" => "completed",
  354. "doing" or "in_progress" => "doing",
  355. _ => "pending",
  356. };
  357. }
  358. private static string? ResolveActionCompletedAt(string status, string? completedAt)
  359. {
  360. if (status != "completed") return null;
  361. if (DateTime.TryParse(completedAt, out var value)) return value.ToString("yyyy-MM-dd");
  362. return DateTime.Today.ToString("yyyy-MM-dd");
  363. }
  364. private static string ActionStatusLabel(string status)
  365. {
  366. return status switch
  367. {
  368. "completed" => "已完成",
  369. "doing" => "进行中",
  370. "overdue" => "逾期",
  371. _ => "未开始",
  372. };
  373. }
  374. private static int NormalizeMetricLevel(int problemLevel)
  375. {
  376. return problemLevel switch
  377. {
  378. <= 1 => 2,
  379. 2 => 3,
  380. >= 3 => 4,
  381. };
  382. }
  383. private static string ResolveKpiValueTable(int metricLevel)
  384. {
  385. return metricLevel switch
  386. {
  387. 1 => "ado_s9_kpi_value_l1_day",
  388. 2 => "ado_s9_kpi_value_l2_day",
  389. 3 => "ado_s9_kpi_value_l3_day",
  390. 4 => "ado_s9_kpi_value_l4_day",
  391. _ => "ado_s9_kpi_value_l2_day",
  392. };
  393. }
  394. private static decimal? ParseDecimal(string? text)
  395. {
  396. if (string.IsNullOrWhiteSpace(text)) return null;
  397. var digits = new string(text.Where(c => char.IsDigit(c) || c is '.' or '-').ToArray());
  398. return decimal.TryParse(digits, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? value : null;
  399. }
  400. private static string? ResolveUnitFromText(string? text)
  401. {
  402. if (string.IsNullOrWhiteSpace(text)) return null;
  403. var unit = new string(text.Where(c => !char.IsDigit(c) && c is not '.' and not '-' and not '/').ToArray()).Trim();
  404. return string.IsNullOrWhiteSpace(unit) ? null : unit;
  405. }
  406. private static List<ImprovementActionItem> ParseActionItems(string? json)
  407. {
  408. if (string.IsNullOrWhiteSpace(json)) return new List<ImprovementActionItem>();
  409. try
  410. {
  411. return JsonSerializer.Deserialize<List<ImprovementActionItem>>(json) ?? new List<ImprovementActionItem>();
  412. }
  413. catch
  414. {
  415. return new List<ImprovementActionItem>();
  416. }
  417. }
  418. }
  419. public class ImprovementPlanCreateInput
  420. {
  421. public long FactoryId { get; set; } = 1;
  422. public string? ModuleCode { get; set; }
  423. public string? MetricCode { get; set; }
  424. public int ProblemLevel { get; set; }
  425. public string? ProblemMetricCode { get; set; }
  426. public string? ProblemName { get; set; }
  427. public string? ProblemDept { get; set; }
  428. public string? TargetValue { get; set; }
  429. public string? ActualValue { get; set; }
  430. public string? GapLabel { get; set; }
  431. public string? RootCause { get; set; }
  432. public List<ImprovementActionItem>? ActionItems { get; set; }
  433. public long? OwnerUserId { get; set; }
  434. public DateTime? DueDate { get; set; }
  435. }
  436. public class ImprovementActionItem
  437. {
  438. public string Content { get; set; } = "";
  439. public string? Owner { get; set; }
  440. public string? DueDate { get; set; }
  441. public string? Status { get; set; }
  442. public string? CompletedAt { get; set; }
  443. }
  444. public class ImprovementPlanPageInput
  445. {
  446. public int Page { get; set; } = 1;
  447. public int PageSize { get; set; } = 20;
  448. public string? ModuleCode { get; set; }
  449. public string? Status { get; set; }
  450. public string? Keyword { get; set; }
  451. }
  452. public class ImprovementPlanIdInput
  453. {
  454. public long Id { get; set; }
  455. public string? Comment { get; set; }
  456. }
  457. public class ImprovementVerificationInput
  458. {
  459. public long Id { get; set; }
  460. public string? VerifyResult { get; set; }
  461. public string? VerifyValue { get; set; }
  462. public string? VerifyRemark { get; set; }
  463. }
  464. public class ImprovementActionItemUpdateInput
  465. {
  466. public long Id { get; set; }
  467. public int Index { get; set; }
  468. public string? Status { get; set; }
  469. public string? CompletedAt { get; set; }
  470. }
  471. public class ImprovementVerificationActionNode
  472. {
  473. public int Index { get; set; }
  474. public string Content { get; set; } = "";
  475. public string? Owner { get; set; }
  476. public string? DueDate { get; set; }
  477. public string Status { get; set; } = "pending";
  478. public string StatusLabel { get; set; } = "未开始";
  479. public string? CompletedAt { get; set; }
  480. }
  481. public class ImprovementVerificationTrendRow
  482. {
  483. public DateTime BizDate { get; set; }
  484. public decimal? MetricValue { get; set; }
  485. public decimal? TargetValue { get; set; }
  486. }