AdoSmartOpsImprovementPlanController.cs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763
  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.AiDOP.SmartOps;
  7. using Admin.NET.Plugin.ApprovalFlow.Service;
  8. namespace Admin.NET.Plugin.AiDOP.Controllers;
  9. [ApiController]
  10. [Route("api/[controller]")]
  11. [NonUnify]
  12. public class AdoSmartOpsImprovementPlanController : ControllerBase
  13. {
  14. private readonly ISqlSugarClient _db;
  15. private readonly FlowEngineService _flowEngine;
  16. private readonly SmartOpsTrustedScopeResolver _trustedScope;
  17. private readonly SmartOpsImprovementActionService _actions;
  18. private readonly SmartOpsImprovementApproverGuard _approverGuard;
  19. public AdoSmartOpsImprovementPlanController(
  20. ISqlSugarClient db,
  21. FlowEngineService flowEngine,
  22. SmartOpsTrustedScopeResolver trustedScope,
  23. SmartOpsImprovementActionService actions,
  24. SmartOpsImprovementApproverGuard approverGuard)
  25. {
  26. _db = db;
  27. _flowEngine = flowEngine;
  28. _trustedScope = trustedScope;
  29. _actions = actions;
  30. _approverGuard = approverGuard;
  31. }
  32. [HttpPost("createFromDiagnosis")]
  33. public async Task<IActionResult> CreateFromDiagnosis([FromBody] ImprovementPlanCreateInput input)
  34. {
  35. var scope = await _trustedScope.ResolveAsync(input.FactoryId);
  36. var tenantId = scope.TenantId;
  37. var now = DateTime.Now;
  38. var entity = new AdoSmartOpsImprovementPlan
  39. {
  40. TenantId = tenantId,
  41. FactoryId = scope.FactoryId,
  42. PlanNo = await GeneratePlanNoAsync(tenantId),
  43. ModuleCode = (input.ModuleCode ?? "").Trim().ToUpperInvariant(),
  44. MetricCode = input.MetricCode,
  45. ProblemLevel = input.ProblemLevel,
  46. ProblemMetricCode = input.ProblemMetricCode,
  47. ProblemName = input.ProblemName ?? "",
  48. ProblemDept = input.ProblemDept,
  49. ProblemSeverity = SmartOpsImprovementApprovalRules.NormalizeSeverity(input.ProblemSeverity),
  50. IsCrossDept = SmartOpsImprovementApprovalRules.IsCrossDept(input.ModuleCode, input.IsCrossDept, input.ProblemDept),
  51. TargetValue = input.TargetValue,
  52. ActualValue = input.ActualValue,
  53. GapLabel = input.GapLabel,
  54. RootCause = input.RootCause,
  55. ActionItemsJson = JsonSerializer.Serialize(input.ActionItems ?? new List<ImprovementActionItem>()),
  56. OwnerUserId = input.OwnerUserId,
  57. DueDate = input.DueDate,
  58. Status = "draft",
  59. CreateTime = now,
  60. UpdateTime = now,
  61. };
  62. if (string.IsNullOrWhiteSpace(entity.ModuleCode)) return BadRequest(new { message = "模块编码不能为空" });
  63. if (string.IsNullOrWhiteSpace(entity.ProblemName)) return BadRequest(new { message = "问题名称不能为空" });
  64. entity.Id = await InsertPlanWithUniqueNoAsync(entity);
  65. await _actions.ReplaceFromDraftAsync(entity, input.ActionItems ?? new List<ImprovementActionItem>(), scope);
  66. return Ok(await ToOutputAsync(entity));
  67. }
  68. [HttpPost("page")]
  69. public async Task<IActionResult> Page([FromBody] ImprovementPlanPageInput input)
  70. {
  71. var tenantId = (await _trustedScope.ResolveAsync()).TenantId;
  72. var page = input.Page <= 0 ? 1 : input.Page;
  73. var pageSize = input.PageSize <= 0 ? 20 : input.PageSize;
  74. var q = ApplyLedgerFilter(_db.Queryable<AdoSmartOpsImprovementPlan>(), tenantId, input)
  75. .OrderByDescending(x => x.CreateTime);
  76. var result = await q.ToPagedListAsync(page, pageSize);
  77. var items = new List<object>();
  78. foreach (var item in result.Items)
  79. items.Add(await ToOutputAsync(item));
  80. return Ok(new
  81. {
  82. result.Page,
  83. result.PageSize,
  84. result.Total,
  85. Items = items
  86. });
  87. }
  88. [HttpPost("export")]
  89. public async Task<IActionResult> Export([FromBody] ImprovementPlanPageInput input)
  90. {
  91. var tenantId = (await _trustedScope.ResolveAsync()).TenantId;
  92. var rows = await ApplyLedgerFilter(_db.Queryable<AdoSmartOpsImprovementPlan>(), tenantId, input)
  93. .OrderByDescending(x => x.CreateTime)
  94. .ToListAsync();
  95. var sb = new System.Text.StringBuilder();
  96. sb.AppendLine("计划号,模块,指标,问题名称,责任部门,责任人,状态,自动评价,到期日,创建时间,是否逾期");
  97. foreach (var x in rows)
  98. {
  99. var overdue = x.DueDate.HasValue && x.DueDate.Value.Date < DateTime.Today && x.Status != "closed";
  100. sb.AppendLine(string.Join(",",
  101. Csv(x.PlanNo),
  102. Csv(x.ModuleCode),
  103. Csv(x.ProblemMetricCode ?? x.MetricCode),
  104. Csv(x.ProblemName),
  105. Csv(x.ProblemDept),
  106. Csv(x.OwnerUserId?.ToString()),
  107. Csv(x.Status),
  108. Csv(x.AutoVerifyResult),
  109. Csv(x.DueDate?.ToString("yyyy-MM-dd")),
  110. Csv(x.CreateTime.ToString("yyyy-MM-dd HH:mm")),
  111. overdue ? "是" : "否"));
  112. }
  113. var bytes = System.Text.Encoding.UTF8.GetPreamble().Concat(System.Text.Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
  114. return File(bytes, "text/csv; charset=utf-8", $"改善计划台账-{DateTime.Today:yyyyMMdd}.csv");
  115. }
  116. [HttpGet("ledgerLogs")]
  117. public async Task<IActionResult> LedgerLogs([FromQuery] long id)
  118. {
  119. var plan = await LoadPlanAsync(id) ?? throw Oops.Oh("改善计划不存在");
  120. var actions = await _actions.ListByPlanAsync(plan.TenantId ?? 0, plan.Id);
  121. var actionLogs = new List<AdoSmartOpsImprovementActionLog>();
  122. foreach (var action in actions)
  123. actionLogs.AddRange(await _actions.ListLogsAsync(plan.TenantId ?? 0, action.Id));
  124. var verifyLogs = await _db.Queryable<AdoSmartOpsImprovementVerifyLog>()
  125. .Where(x => x.TenantId == plan.TenantId && x.PlanId == plan.Id)
  126. .OrderByDescending(x => x.CreateTime)
  127. .ToListAsync();
  128. return Ok(new { actionLogs, verifyLogs });
  129. }
  130. [HttpGet("detail")]
  131. public async Task<IActionResult> Detail([FromQuery] long id)
  132. {
  133. var tenantId = (await _trustedScope.ResolveAsync()).TenantId;
  134. var entity = await _db.Queryable<AdoSmartOpsImprovementPlan>()
  135. .FirstAsync(x => x.Id == id && x.TenantId == tenantId);
  136. return entity == null ? NotFound() : Ok(await ToOutputAsync(entity));
  137. }
  138. [HttpGet("verificationDashboard")]
  139. public async Task<IActionResult> VerificationDashboard([FromQuery] long id)
  140. {
  141. var plan = await LoadPlanAsync(id) ?? throw Oops.Oh("改善计划不存在");
  142. var actionRows = await _actions.ListByPlanAsync(plan.TenantId ?? 0, plan.Id);
  143. var actions = SmartOpsImprovementActionService.ToLegacyItems(actionRows);
  144. var startDate = plan.CreateTime.Date;
  145. var endDate = ResolveVerificationEndDate(plan, actions);
  146. if (endDate < startDate) endDate = startDate;
  147. var queryStart = startDate.AddDays(-30);
  148. var metricCode = (plan.ProblemMetricCode ?? plan.MetricCode ?? "").Trim();
  149. var metricMeta = string.IsNullOrWhiteSpace(metricCode)
  150. ? null
  151. : await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.TenantId == plan.TenantId && x.MetricCode == metricCode);
  152. var metricLevel = metricMeta?.MetricLevel ?? NormalizeMetricLevel(plan.ProblemLevel);
  153. var targetFallback = ParseDecimal(plan.TargetValue);
  154. var table = ResolveKpiValueTable(metricLevel);
  155. var trendRows = await QueryVerificationTrendAsync(table, plan, metricCode, queryStart, endDate);
  156. if (trendRows.Count == 0)
  157. {
  158. 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" })
  159. {
  160. if (fallbackTable == table) continue;
  161. trendRows = await QueryVerificationTrendAsync(fallbackTable, plan, metricCode, queryStart, endDate);
  162. if (trendRows.Count > 0) break;
  163. }
  164. }
  165. var trendByDate = trendRows
  166. .GroupBy(x => x.BizDate.Date)
  167. .ToDictionary(x => x.Key, x => x.OrderByDescending(r => r.TargetValue.HasValue).First());
  168. var timeline = BuildDateRange(startDate, endDate)
  169. .Select(day => new
  170. {
  171. Date = day.ToString("yyyy-MM-dd"),
  172. IsStart = day == startDate,
  173. IsEnd = day == endDate,
  174. Actions = actions
  175. .Select((action, index) => BuildVerificationActionNode(action, index, day))
  176. .Where(x => x != null)
  177. .ToList(),
  178. })
  179. .ToList();
  180. var series = BuildDateRange(startDate, endDate)
  181. .Select(day =>
  182. {
  183. trendByDate.TryGetValue(day, out var row);
  184. return new
  185. {
  186. Date = day.ToString("yyyy-MM-dd"),
  187. Value = row?.MetricValue,
  188. Target = row?.TargetValue ?? targetFallback,
  189. };
  190. })
  191. .ToList();
  192. return Ok(new
  193. {
  194. Plan = await ToOutputAsync(plan),
  195. Metric = new
  196. {
  197. MetricCode = metricCode,
  198. MetricName = metricMeta?.MetricName ?? plan.ProblemName,
  199. Unit = metricMeta?.Unit ?? ResolveUnitFromText(plan.TargetValue),
  200. TargetValue = targetFallback,
  201. Level = metricLevel,
  202. Direction = metricMeta?.Direction ?? "higher_is_better",
  203. },
  204. StartDate = startDate.ToString("yyyy-MM-dd"),
  205. EndDate = endDate.ToString("yyyy-MM-dd"),
  206. Timeline = timeline,
  207. Trend = series,
  208. Effectiveness = EvaluateEffectiveness(plan, actions, trendRows, metricMeta?.Direction, targetFallback),
  209. });
  210. }
  211. [HttpPost("submitApproval")]
  212. public async Task<IActionResult> SubmitApproval([FromBody] ImprovementPlanIdInput input)
  213. {
  214. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  215. if (plan.Status is "approving" or "approved" or "executing" or "pending_verify" or "closed")
  216. return BadRequest(new { message = "当前状态不可提交审批" });
  217. try
  218. {
  219. await _approverGuard.EnsureResolvableAsync(plan);
  220. var route = SmartOpsImprovementApprovalRules.ResolveRoute(plan);
  221. var instanceId = await _flowEngine.StartFlow(new StartFlowInput
  222. {
  223. BizType = SmartOpsImprovementBizHandler.BizTypeCode,
  224. BizId = plan.Id,
  225. BizNo = plan.PlanNo,
  226. Title = $"智慧诊断改善计划-{plan.PlanNo}",
  227. Comment = input.Comment,
  228. BizData = new Dictionary<string, object>
  229. {
  230. ["moduleCode"] = plan.ModuleCode,
  231. ["problemLevel"] = plan.ProblemLevel,
  232. ["problemName"] = plan.ProblemName,
  233. ["problemDept"] = plan.ProblemDept ?? "",
  234. ["problemSeverity"] = SmartOpsImprovementApprovalRules.NormalizeSeverity(plan.ProblemSeverity),
  235. ["isCrossDept"] = plan.IsCrossDept ? 1 : 0,
  236. ["route"] = route,
  237. }
  238. });
  239. plan = await LoadPlanAsync(input.Id) ?? plan;
  240. return Ok(new { instanceId, plan = await ToOutputAsync(plan) });
  241. }
  242. catch (Exception ex)
  243. {
  244. return BadRequest(new { message = $"提交审批失败:{ex.Message}" });
  245. }
  246. }
  247. [HttpPost("startExecution")]
  248. public async Task<IActionResult> StartExecution([FromBody] ImprovementPlanIdInput input)
  249. {
  250. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  251. if (plan.Status != "approved" && plan.Status != "executing")
  252. return BadRequest(new { message = "审批通过后才能开始执行" });
  253. await UpdateStatusAsync(plan.Id, plan.TenantId, "executing");
  254. return Ok(await ToOutputAsync((await LoadPlanAsync(plan.Id))!));
  255. }
  256. [HttpPost("updateActionItem")]
  257. public async Task<IActionResult> UpdateActionItem([FromBody] ImprovementActionItemUpdateInput input)
  258. {
  259. var scope = await _trustedScope.ResolveAsync();
  260. await _actions.MutateAsync(
  261. input.Id,
  262. input.Index,
  263. input.ActionId,
  264. input.Status,
  265. input.CompletedAt,
  266. input.OwnerUserId,
  267. input.Owner,
  268. input.ProofRemark,
  269. scope);
  270. return Ok(await ToOutputAsync((await LoadPlanAsync(input.Id))!));
  271. }
  272. [HttpGet("myActions")]
  273. public async Task<IActionResult> MyActions()
  274. {
  275. var scope = await _trustedScope.ResolveAsync();
  276. var items = await _actions.ListMineAsync(scope);
  277. return Ok(items.Select(SmartOpsImprovementActionService.ToApiItem).ToList());
  278. }
  279. [HttpGet("actionLogs")]
  280. public async Task<IActionResult> ActionLogs([FromQuery] long actionId)
  281. {
  282. var scope = await _trustedScope.ResolveAsync();
  283. return Ok(await _actions.ListLogsAsync(scope.TenantId, actionId));
  284. }
  285. [HttpPost("submitVerification")]
  286. public async Task<IActionResult> SubmitVerification([FromBody] ImprovementVerificationInput input)
  287. {
  288. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  289. if (plan.Status is not ("executing" or "pending_verify"))
  290. return BadRequest(new { message = "执行中计划才能提交验证" });
  291. var scope = await _trustedScope.ResolveAsync();
  292. var actionRows = await _actions.ListByPlanAsync(plan.TenantId ?? 0, plan.Id);
  293. var actions = SmartOpsImprovementActionService.ToLegacyItems(actionRows);
  294. var metricCode = (plan.ProblemMetricCode ?? plan.MetricCode ?? "").Trim();
  295. var metricMeta = string.IsNullOrWhiteSpace(metricCode)
  296. ? null
  297. : await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.TenantId == plan.TenantId && x.MetricCode == metricCode);
  298. var startDate = plan.CreateTime.Date;
  299. var endDate = ResolveVerificationEndDate(plan, actions);
  300. if (endDate < startDate) endDate = startDate;
  301. var table = ResolveKpiValueTable(metricMeta?.MetricLevel ?? NormalizeMetricLevel(plan.ProblemLevel));
  302. var trendRows = await QueryVerificationTrendAsync(table, plan, metricCode, startDate.AddDays(-30), endDate);
  303. var auto = EvaluateEffectiveness(plan, actions, trendRows, metricMeta?.Direction, ParseDecimal(plan.TargetValue));
  304. if (ManualDiffersFromAuto(input.VerifyResult, auto.Conclusion) && string.IsNullOrWhiteSpace(input.VerifyRemark))
  305. return BadRequest(new { message = "人工结论与自动评价不一致时必须填写理由" });
  306. var now = DateTime.Now;
  307. var autoJson = JsonSerializer.Serialize(auto);
  308. await _db.Updateable<AdoSmartOpsImprovementPlan>()
  309. .SetColumns(x => x.Status == "pending_verify")
  310. .SetColumns(x => x.VerifyResult == input.VerifyResult)
  311. .SetColumns(x => x.VerifyValue == input.VerifyValue)
  312. .SetColumns(x => x.VerifyRemark == input.VerifyRemark)
  313. .SetColumns(x => x.AutoVerifyResult == auto.Conclusion)
  314. .SetColumns(x => x.AutoVerifyJson == autoJson)
  315. .SetColumns(x => x.AutoVerifyTime == now)
  316. .SetColumns(x => x.UpdateTime == now)
  317. .Where(x => x.Id == plan.Id && x.TenantId == plan.TenantId)
  318. .ExecuteCommandAsync();
  319. await _db.Insertable(new AdoSmartOpsImprovementVerifyLog
  320. {
  321. TenantId = plan.TenantId,
  322. PlanId = plan.Id,
  323. AutoResult = auto.Conclusion,
  324. ManualResult = input.VerifyResult,
  325. Remark = input.VerifyRemark,
  326. OperatorUserId = scope.UserId,
  327. CreateTime = now,
  328. }).ExecuteCommandAsync();
  329. return Ok(await ToOutputAsync((await LoadPlanAsync(plan.Id))!));
  330. }
  331. [HttpPost("close")]
  332. public async Task<IActionResult> Close([FromBody] ImprovementPlanIdInput input)
  333. {
  334. var plan = await LoadPlanAsync(input.Id) ?? throw Oops.Oh("改善计划不存在");
  335. if (plan.Status != "pending_verify")
  336. return BadRequest(new { message = "提交验证后才能关闭" });
  337. var openActions = (await _actions.ListByPlanAsync(plan.TenantId ?? 0, plan.Id))
  338. .Where(x => SmartOpsImprovementActionStatus.IsOpen(x.Status))
  339. .Select(x => x.Id)
  340. .ToList();
  341. if (openActions.Count > 0)
  342. return BadRequest(new { message = "仍有未完成的行动项,不能关闭" });
  343. await UpdateStatusAsync(plan.Id, plan.TenantId, "closed");
  344. return Ok(await ToOutputAsync((await LoadPlanAsync(plan.Id))!));
  345. }
  346. private async Task<AdoSmartOpsImprovementPlan?> LoadPlanAsync(long id)
  347. {
  348. var tenantId = (await _trustedScope.ResolveAsync()).TenantId;
  349. return await _db.Queryable<AdoSmartOpsImprovementPlan>().FirstAsync(x => x.Id == id && x.TenantId == tenantId);
  350. }
  351. private async Task UpdateStatusAsync(long id, long? tenantId, string status)
  352. {
  353. await _db.Updateable<AdoSmartOpsImprovementPlan>()
  354. .SetColumns(x => x.Status == status)
  355. .SetColumns(x => x.UpdateTime == DateTime.Now)
  356. .Where(x => x.Id == id && x.TenantId == tenantId)
  357. .ExecuteCommandAsync();
  358. }
  359. private async Task<string> GeneratePlanNoAsync(long tenantId)
  360. {
  361. var prefix = SmartOpsImprovementPlanNo.BuildPrefix(DateTime.Now);
  362. var existing = await _db.Queryable<AdoSmartOpsImprovementPlan>()
  363. .Where(x => x.TenantId == tenantId && x.PlanNo.StartsWith(prefix))
  364. .Select(x => x.PlanNo)
  365. .ToListAsync();
  366. return SmartOpsImprovementPlanNo.Next(prefix, existing);
  367. }
  368. private async Task<long> InsertPlanWithUniqueNoAsync(AdoSmartOpsImprovementPlan entity)
  369. {
  370. for (var attempt = 0; attempt < 8; attempt++)
  371. {
  372. try
  373. {
  374. return await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
  375. }
  376. catch (Exception ex) when (attempt < 7 && IsDuplicatePlanNo(ex))
  377. {
  378. entity.PlanNo = await GeneratePlanNoAsync(entity.TenantId ?? 0);
  379. }
  380. }
  381. throw Oops.Oh("改善计划编号生成失败,请重试");
  382. }
  383. private static bool IsDuplicatePlanNo(Exception ex)
  384. {
  385. for (var current = ex; current != null; current = current.InnerException)
  386. {
  387. var message = current.Message ?? "";
  388. if (message.Contains("Duplicate", StringComparison.OrdinalIgnoreCase)
  389. || message.Contains("uk_smart_ops_improve_tenant_plan", StringComparison.OrdinalIgnoreCase))
  390. return true;
  391. }
  392. return false;
  393. }
  394. private async Task<object> ToOutputAsync(AdoSmartOpsImprovementPlan x)
  395. {
  396. var actionRows = x.TenantId is > 0
  397. ? await _actions.ListByPlanAsync(x.TenantId.Value, x.Id)
  398. : new List<AdoSmartOpsImprovementAction>();
  399. return new
  400. {
  401. x.Id,
  402. x.PlanNo,
  403. x.ModuleCode,
  404. x.MetricCode,
  405. x.ProblemLevel,
  406. x.ProblemMetricCode,
  407. x.ProblemName,
  408. x.ProblemDept,
  409. x.ProblemSeverity,
  410. x.IsCrossDept,
  411. ApprovalRoute = SmartOpsImprovementApprovalRules.ResolveRoute(x),
  412. x.TargetValue,
  413. x.ActualValue,
  414. x.GapLabel,
  415. x.RootCause,
  416. ActionItems = actionRows.Select(SmartOpsImprovementActionService.ToApiItem).ToList(),
  417. x.OwnerUserId,
  418. DueDate = x.DueDate?.ToString("yyyy-MM-dd"),
  419. x.Status,
  420. x.FlowInstanceId,
  421. x.VerifyResult,
  422. x.VerifyValue,
  423. x.VerifyRemark,
  424. x.AutoVerifyResult,
  425. x.AutoVerifyJson,
  426. x.AutoVerifyTime,
  427. Overdue = x.DueDate.HasValue && x.DueDate.Value.Date < DateTime.Today && x.Status != "closed",
  428. x.CreateTime,
  429. x.UpdateTime,
  430. };
  431. }
  432. private async Task<List<ImprovementVerificationTrendRow>> QueryVerificationTrendAsync(
  433. string table,
  434. AdoSmartOpsImprovementPlan plan,
  435. string metricCode,
  436. DateTime startDate,
  437. DateTime endDate)
  438. {
  439. if (string.IsNullOrWhiteSpace(metricCode)) return new List<ImprovementVerificationTrendRow>();
  440. var sql = $"""
  441. SELECT biz_date AS BizDate, metric_value AS MetricValue, target_value AS TargetValue
  442. FROM {table}
  443. WHERE tenant_id=@tenantId AND module_code=@moduleCode
  444. AND metric_code=@metricCode AND is_deleted=0 AND biz_date BETWEEN @startDate AND @endDate
  445. AND (factory_id IS NULL OR factory_id=0 OR factory_id=1 OR factory_id=@factoryId)
  446. AND (factory_id IS NULL OR factory_id<>@tenantId)
  447. ORDER BY biz_date
  448. """;
  449. return await _db.Ado.SqlQueryAsync<ImprovementVerificationTrendRow>(sql, new
  450. {
  451. tenantId = plan.TenantId,
  452. factoryId = plan.FactoryId <= 0 ? 1 : plan.FactoryId,
  453. moduleCode = plan.ModuleCode,
  454. metricCode,
  455. startDate,
  456. endDate,
  457. });
  458. }
  459. private static SmartOpsEffectivenessResult EvaluateEffectiveness(
  460. AdoSmartOpsImprovementPlan plan,
  461. List<ImprovementActionItem> actions,
  462. List<ImprovementVerificationTrendRow> trendRows,
  463. string? direction,
  464. decimal? target)
  465. {
  466. var create = plan.CreateTime.Date;
  467. var afterEnd = ResolveVerificationEndDate(plan, actions);
  468. var before = SmartOpsImprovementEffectiveness.TakeLastValid(
  469. trendRows.Where(x => x.BizDate.Date < create).OrderBy(x => x.BizDate).Select(x => x.MetricValue));
  470. var after = SmartOpsImprovementEffectiveness.TakeLastValid(
  471. trendRows.Where(x => x.BizDate.Date >= create && x.BizDate.Date <= afterEnd).OrderBy(x => x.BizDate).Select(x => x.MetricValue));
  472. var exBefore = before.Count(v => !SmartOpsImprovementEffectiveness.MeetsTarget(v, target, direction));
  473. var exAfter = after.Count(v => !SmartOpsImprovementEffectiveness.MeetsTarget(v, target, direction));
  474. return SmartOpsImprovementEffectiveness.Evaluate(before, after, target, direction, exBefore, exAfter);
  475. }
  476. private static bool ManualDiffersFromAuto(string? manual, string auto)
  477. {
  478. var mapped = (manual ?? "").Trim().ToLowerInvariant() switch
  479. {
  480. "effective" => SmartOpsImprovementEffectiveness.AutoEffective,
  481. "ineffective" => SmartOpsImprovementEffectiveness.AutoIneffective,
  482. "partial" => SmartOpsImprovementEffectiveness.AutoInconclusive,
  483. _ => "",
  484. };
  485. return mapped.Length > 0 && !string.Equals(mapped, auto, StringComparison.OrdinalIgnoreCase);
  486. }
  487. private static ISugarQueryable<AdoSmartOpsImprovementPlan> ApplyLedgerFilter(
  488. ISugarQueryable<AdoSmartOpsImprovementPlan> q,
  489. long tenantId,
  490. ImprovementPlanPageInput input)
  491. {
  492. var today = DateTime.Today;
  493. return q.Where(x => x.TenantId == tenantId)
  494. .WhereIF(!string.IsNullOrWhiteSpace(input.ModuleCode), x => x.ModuleCode == input.ModuleCode)
  495. .WhereIF(!string.IsNullOrWhiteSpace(input.Status), x => x.Status == input.Status)
  496. .WhereIF(!string.IsNullOrWhiteSpace(input.MetricCode), x => x.MetricCode == input.MetricCode || x.ProblemMetricCode == input.MetricCode)
  497. .WhereIF(!string.IsNullOrWhiteSpace(input.ProblemDept), x => x.ProblemDept != null && x.ProblemDept.Contains(input.ProblemDept!))
  498. .WhereIF(input.OwnerUserId is > 0, x => x.OwnerUserId == input.OwnerUserId)
  499. .WhereIF(input.CreateFrom.HasValue, x => x.CreateTime >= input.CreateFrom)
  500. .WhereIF(input.CreateTo.HasValue, x => x.CreateTime < input.CreateTo!.Value.Date.AddDays(1))
  501. .WhereIF(input.DueFrom.HasValue, x => x.DueDate >= input.DueFrom)
  502. .WhereIF(input.DueTo.HasValue, x => x.DueDate < input.DueTo!.Value.Date.AddDays(1))
  503. .WhereIF(!string.IsNullOrWhiteSpace(input.AutoVerifyResult), x => x.AutoVerifyResult == input.AutoVerifyResult)
  504. .WhereIF(input.Overdue == true, x => x.DueDate != null && x.DueDate < today && x.Status != "closed")
  505. .WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), x => x.PlanNo.Contains(input.Keyword!) || x.ProblemName.Contains(input.Keyword!));
  506. }
  507. private static string Csv(string? value)
  508. {
  509. var text = value ?? "";
  510. if (text.Contains(',') || text.Contains('"') || text.Contains('\n'))
  511. return "\"" + text.Replace("\"", "\"\"") + "\"";
  512. return text;
  513. }
  514. private static DateTime ResolveVerificationEndDate(AdoSmartOpsImprovementPlan plan, List<ImprovementActionItem> actions)
  515. {
  516. var candidates = new List<DateTime> { plan.CreateTime.Date };
  517. if (plan.DueDate.HasValue) candidates.Add(plan.DueDate.Value.Date);
  518. foreach (var action in actions)
  519. {
  520. if (DateTime.TryParse(action.DueDate, out var dueDate)) candidates.Add(dueDate.Date);
  521. if (DateTime.TryParse(action.CompletedAt, out var completedAt)) candidates.Add(completedAt.Date);
  522. }
  523. return candidates.Max();
  524. }
  525. private static IEnumerable<DateTime> BuildDateRange(DateTime startDate, DateTime endDate)
  526. {
  527. for (var day = startDate.Date; day <= endDate.Date; day = day.AddDays(1))
  528. yield return day;
  529. }
  530. private static ImprovementVerificationActionNode? BuildVerificationActionNode(ImprovementActionItem action, int index, DateTime day)
  531. {
  532. if (!DateTime.TryParse(action.DueDate, out var dueDate) || dueDate.Date != day.Date) return null;
  533. var status = NormalizeActionStatus(action.Status, action.CompletedAt, dueDate.Date);
  534. return new ImprovementVerificationActionNode
  535. {
  536. Index = index + 1,
  537. Content = action.Content,
  538. Owner = action.Owner,
  539. DueDate = dueDate.ToString("yyyy-MM-dd"),
  540. Status = status,
  541. StatusLabel = ActionStatusLabel(status),
  542. CompletedAt = DateTime.TryParse(action.CompletedAt, out var completedAt) ? completedAt.ToString("yyyy-MM-dd") : null,
  543. };
  544. }
  545. private static string NormalizeActionStatus(string? status, string? completedAt, DateTime dueDate)
  546. {
  547. var normalized = (status ?? "").Trim().ToLowerInvariant();
  548. if (normalized is "completed" or "done") return "completed";
  549. if (normalized is "doing" or "in_progress") return "doing";
  550. if (!string.IsNullOrWhiteSpace(completedAt)) return "completed";
  551. if (dueDate < DateTime.Today) return "overdue";
  552. return string.IsNullOrWhiteSpace(normalized) ? "pending" : normalized;
  553. }
  554. private static string NormalizeManualActionStatus(string? status)
  555. {
  556. var normalized = (status ?? "").Trim().ToLowerInvariant();
  557. return normalized switch
  558. {
  559. "completed" or "done" => "completed",
  560. "doing" or "in_progress" => "doing",
  561. _ => "pending",
  562. };
  563. }
  564. private static string? ResolveActionCompletedAt(string status, string? completedAt)
  565. {
  566. if (status != "completed") return null;
  567. if (DateTime.TryParse(completedAt, out var value)) return value.ToString("yyyy-MM-dd");
  568. return DateTime.Today.ToString("yyyy-MM-dd");
  569. }
  570. private static string ActionStatusLabel(string status)
  571. {
  572. return status switch
  573. {
  574. "completed" => "已完成",
  575. "doing" => "进行中",
  576. "overdue" => "逾期",
  577. _ => "未开始",
  578. };
  579. }
  580. private static int NormalizeMetricLevel(int problemLevel)
  581. {
  582. return problemLevel switch
  583. {
  584. <= 1 => 2,
  585. 2 => 3,
  586. >= 3 => 4,
  587. };
  588. }
  589. private static string ResolveKpiValueTable(int metricLevel)
  590. {
  591. return metricLevel switch
  592. {
  593. 1 => "ado_s9_kpi_value_l1_day",
  594. 2 => "ado_s9_kpi_value_l2_day",
  595. 3 => "ado_s9_kpi_value_l3_day",
  596. 4 => "ado_s9_kpi_value_l4_day",
  597. _ => "ado_s9_kpi_value_l2_day",
  598. };
  599. }
  600. private static decimal? ParseDecimal(string? text)
  601. {
  602. if (string.IsNullOrWhiteSpace(text)) return null;
  603. var digits = new string(text.Where(c => char.IsDigit(c) || c is '.' or '-').ToArray());
  604. return decimal.TryParse(digits, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? value : null;
  605. }
  606. private static string? ResolveUnitFromText(string? text)
  607. {
  608. if (string.IsNullOrWhiteSpace(text)) return null;
  609. var unit = new string(text.Where(c => !char.IsDigit(c) && c is not '.' and not '-' and not '/').ToArray()).Trim();
  610. return string.IsNullOrWhiteSpace(unit) ? null : unit;
  611. }
  612. private static List<ImprovementActionItem> ParseActionItems(string? json)
  613. {
  614. if (string.IsNullOrWhiteSpace(json)) return new List<ImprovementActionItem>();
  615. try
  616. {
  617. return JsonSerializer.Deserialize<List<ImprovementActionItem>>(json) ?? new List<ImprovementActionItem>();
  618. }
  619. catch
  620. {
  621. return new List<ImprovementActionItem>();
  622. }
  623. }
  624. }
  625. public class ImprovementPlanCreateInput
  626. {
  627. /// <summary>客户端工厂仅作筛选建议,服务端以租户 OrgId 为准。</summary>
  628. public long FactoryId { get; set; } = 1;
  629. public string? ModuleCode { get; set; }
  630. public string? MetricCode { get; set; }
  631. public int ProblemLevel { get; set; }
  632. public string? ProblemMetricCode { get; set; }
  633. public string? ProblemName { get; set; }
  634. public string? ProblemDept { get; set; }
  635. public string? ProblemSeverity { get; set; }
  636. public bool IsCrossDept { get; set; }
  637. public string? TargetValue { get; set; }
  638. public string? ActualValue { get; set; }
  639. public string? GapLabel { get; set; }
  640. public string? RootCause { get; set; }
  641. public List<ImprovementActionItem>? ActionItems { get; set; }
  642. public long? OwnerUserId { get; set; }
  643. public DateTime? DueDate { get; set; }
  644. }
  645. public class ImprovementActionItem
  646. {
  647. public string Content { get; set; } = "";
  648. public string? Owner { get; set; }
  649. public string? DueDate { get; set; }
  650. public string? Status { get; set; }
  651. public string? CompletedAt { get; set; }
  652. }
  653. public class ImprovementPlanPageInput
  654. {
  655. public int Page { get; set; } = 1;
  656. public int PageSize { get; set; } = 20;
  657. public string? ModuleCode { get; set; }
  658. public string? Status { get; set; }
  659. public string? Keyword { get; set; }
  660. public string? MetricCode { get; set; }
  661. public string? ProblemDept { get; set; }
  662. public long? OwnerUserId { get; set; }
  663. public DateTime? CreateFrom { get; set; }
  664. public DateTime? CreateTo { get; set; }
  665. public DateTime? DueFrom { get; set; }
  666. public DateTime? DueTo { get; set; }
  667. public bool? Overdue { get; set; }
  668. public string? AutoVerifyResult { get; set; }
  669. }
  670. public class ImprovementPlanIdInput
  671. {
  672. public long Id { get; set; }
  673. public string? Comment { get; set; }
  674. }
  675. public class ImprovementVerificationInput
  676. {
  677. public long Id { get; set; }
  678. public string? VerifyResult { get; set; }
  679. public string? VerifyValue { get; set; }
  680. public string? VerifyRemark { get; set; }
  681. }
  682. public class ImprovementActionItemUpdateInput
  683. {
  684. public long Id { get; set; }
  685. public int Index { get; set; }
  686. public long? ActionId { get; set; }
  687. public string? Status { get; set; }
  688. public string? CompletedAt { get; set; }
  689. public long? OwnerUserId { get; set; }
  690. public string? Owner { get; set; }
  691. public string? ProofRemark { get; set; }
  692. }
  693. public class ImprovementVerificationActionNode
  694. {
  695. public int Index { get; set; }
  696. public string Content { get; set; } = "";
  697. public string? Owner { get; set; }
  698. public string? DueDate { get; set; }
  699. public string Status { get; set; } = "pending";
  700. public string StatusLabel { get; set; } = "未开始";
  701. public string? CompletedAt { get; set; }
  702. }
  703. public class ImprovementVerificationTrendRow
  704. {
  705. public DateTime BizDate { get; set; }
  706. public decimal? MetricValue { get; set; }
  707. public decimal? TargetValue { get; set; }
  708. }