AdoSmartOpsImprovementPlanController.cs 36 KB

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