KpiTargetExcelService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. using System.Security.Cryptography;
  2. using System.Text.Json;
  3. using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
  4. using Admin.NET.Plugin.AiDOP.Entity;
  5. using Microsoft.AspNetCore.Http;
  6. using MiniExcelLibs;
  7. using MiniExcelLibs.Attributes;
  8. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  9. public sealed class KpiTargetExcelRow
  10. {
  11. [ExcelColumnName("指标编码 ★")] public string? MetricCode { get; set; }
  12. [ExcelColumnName("工厂编码")] public string? FactoryCode { get; set; }
  13. [ExcelColumnName("目标值 ★")] public string? TargetValue { get; set; }
  14. [ExcelColumnName("生效日期 ★")] public string? EffectiveFrom { get; set; }
  15. [ExcelColumnName("失效日期")] public string? EffectiveTo { get; set; }
  16. [ExcelColumnName("备注")] public string? Remark { get; set; }
  17. }
  18. public sealed class KpiTargetExcelService : ITransient
  19. {
  20. private readonly IKpiTargetConfigStore _store;
  21. private readonly KpiTargetService _targets;
  22. private readonly UserManager _userManager;
  23. public KpiTargetExcelService(IKpiTargetConfigStore store, KpiTargetService targets, UserManager userManager)
  24. {
  25. _store = store;
  26. _targets = targets;
  27. _userManager = userManager;
  28. }
  29. public async Task<IActionResult> TemplateAsync()
  30. {
  31. var memory = new MemoryStream();
  32. await memory.SaveAsAsync(Array.Empty<KpiTargetExcelRow>(), sheetName: "目标");
  33. memory.Seek(0, SeekOrigin.Begin);
  34. return new FileStreamResult(memory, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
  35. {
  36. FileDownloadName = "kpi-target-template.xlsx"
  37. };
  38. }
  39. public async Task<KpiTargetImportPreviewDto> ValidateAsync(IFormFile file, long factoryIdFallback, CancellationToken ct = default)
  40. {
  41. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  42. if (file == null || file.Length == 0)
  43. throw new KpiTargetValidationException("请上传 Excel 文件");
  44. if (file.Length > KpiTargetService.MaxImportBytes)
  45. throw new KpiTargetValidationException("文件超过 5MB");
  46. var ext = Path.GetExtension(file.FileName);
  47. if (!string.Equals(ext, ".xlsx", StringComparison.OrdinalIgnoreCase))
  48. throw new KpiTargetValidationException("仅支持 .xlsx");
  49. await using var stream = file.OpenReadStream();
  50. var sha = await Sha256Async(stream, ct);
  51. stream.Position = 0;
  52. if (await _store.CountCommittedHashAsync(tenantId, sha, ct) > 0)
  53. throw new KpiTargetConflictException("相同文件已成功导入,拒绝重复提交");
  54. var raw = (await MiniExcel.QueryAsync<KpiTargetExcelRow>(stream, sheetName: "目标")).ToList();
  55. if (raw.Count > KpiTargetService.MaxImportRows)
  56. throw new KpiTargetValidationException($"超过最大行数 {KpiTargetService.MaxImportRows}");
  57. var errors = new List<KpiTargetImportErrorDto>();
  58. var valid = new List<KpiTargetImportRow>();
  59. for (var i = 0; i < raw.Count; i++)
  60. {
  61. var rowNo = i + 2;
  62. var line = raw[i];
  63. try
  64. {
  65. valid.Add(await ParseRowAsync(tenantId, line, rowNo, factoryIdFallback, ct));
  66. }
  67. catch (Exception ex) when (ex is KpiTargetValidationException or KpiTargetNotFoundException or KpiTargetConflictException)
  68. {
  69. errors.Add(new KpiTargetImportErrorDto
  70. {
  71. RowNo = rowNo,
  72. MetricCode = line.MetricCode,
  73. Code = ex.GetType().Name,
  74. Message = ex.Message
  75. });
  76. }
  77. }
  78. DetectFileOverlaps(valid, errors);
  79. valid = valid.Where(v => errors.All(e => e.RowNo != v.RowNo)).ToList();
  80. foreach (var row in valid.ToList())
  81. {
  82. var existing = await _store.ListActiveScopeAsync(tenantId, row.FactoryId, row.MetricCode, ct);
  83. foreach (var cfg in existing)
  84. {
  85. if (KpiTargetRules.Overlaps(cfg.EffectiveFrom, cfg.EffectiveTo, row.EffectiveFrom, row.EffectiveTo))
  86. {
  87. errors.Add(new KpiTargetImportErrorDto
  88. {
  89. RowNo = row.RowNo,
  90. MetricCode = row.MetricCode,
  91. Code = "OVERLAP_DB",
  92. Message = $"与库中配置 {cfg.Id} 重叠"
  93. });
  94. valid.Remove(row);
  95. break;
  96. }
  97. }
  98. }
  99. var now = DateTime.Now;
  100. var batch = await _store.InsertBatchAsync(new AdoSmartOpsKpiTargetImportBatch
  101. {
  102. BatchNo = $"KPI_TGT_{now:yyyyMMddHHmmss}_{Random.Shared.Next(1000, 9999)}",
  103. TenantId = tenantId,
  104. FactoryId = factoryIdFallback,
  105. FileName = file.FileName,
  106. FileSha256 = sha,
  107. Status = errors.Count == 0 && valid.Count > 0 ? "READY" : "VALIDATION_FAILED",
  108. TotalRows = raw.Count,
  109. ValidRows = valid.Count,
  110. ErrorRows = errors.Count,
  111. PayloadJson = KpiTargetService.SerializePayload(valid),
  112. ErrorJson = JsonSerializer.Serialize(errors),
  113. CreatedBy = _userManager.RealName ?? _userManager.Account,
  114. CreateTime = now
  115. }, ct);
  116. return new KpiTargetImportPreviewDto
  117. {
  118. BatchId = batch.Id,
  119. BatchNo = batch.BatchNo,
  120. Status = batch.Status,
  121. TotalRows = batch.TotalRows,
  122. ValidRows = batch.ValidRows,
  123. ErrorRows = batch.ErrorRows,
  124. Errors = errors
  125. };
  126. }
  127. public async Task<KpiTargetImportPreviewDto> CommitAsync(long batchId, CancellationToken ct = default)
  128. {
  129. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  130. var batch = await _store.GetBatchAsync(tenantId, batchId, ct)
  131. ?? throw new KpiTargetNotFoundException("导入批次不存在");
  132. if (batch.Status == "COMMITTED")
  133. throw new KpiTargetConflictException("该批次已提交,不能重复执行");
  134. if (batch.Status != "READY")
  135. throw new KpiTargetValidationException("只有预检通过的批次可以确认导入");
  136. var rows = KpiTargetService.DeserializePayload(batch.PayloadJson);
  137. foreach (var row in rows)
  138. {
  139. var existing = await _store.ListActiveScopeAsync(tenantId, row.FactoryId, row.MetricCode, ct);
  140. foreach (var cfg in existing)
  141. {
  142. if (KpiTargetRules.Overlaps(cfg.EffectiveFrom, cfg.EffectiveTo, row.EffectiveFrom, row.EffectiveTo))
  143. throw new KpiTargetConflictException($"并发冲突:{row.MetricCode} 与配置 {cfg.Id} 重叠");
  144. }
  145. }
  146. try
  147. {
  148. foreach (var row in rows)
  149. {
  150. await _targets.CreateCoreAsync(tenantId, new KpiTargetCreateDto
  151. {
  152. MetricCode = row.MetricCode,
  153. FactoryId = row.FactoryId,
  154. TargetValue = row.TargetValue,
  155. EffectiveFrom = row.EffectiveFrom,
  156. EffectiveTo = row.EffectiveTo,
  157. ClosePrevious = false,
  158. Remark = row.Remark
  159. }, KpiTargetConfigSource.TargetExcel, batch.BatchNo, _userManager.UserId, ct);
  160. }
  161. batch.Status = "COMMITTED";
  162. batch.CommitTime = DateTime.Now;
  163. await _store.UpdateBatchAsync(batch, ct);
  164. }
  165. catch
  166. {
  167. batch.Status = "FAILED";
  168. await _store.UpdateBatchAsync(batch, ct);
  169. throw;
  170. }
  171. return new KpiTargetImportPreviewDto
  172. {
  173. BatchId = batch.Id,
  174. BatchNo = batch.BatchNo,
  175. Status = batch.Status,
  176. TotalRows = batch.TotalRows,
  177. ValidRows = batch.ValidRows,
  178. ErrorRows = batch.ErrorRows
  179. };
  180. }
  181. public async Task<IActionResult> ErrorsAsync(long batchId, CancellationToken ct = default)
  182. {
  183. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  184. var batch = await _store.GetBatchAsync(tenantId, batchId, ct)
  185. ?? throw new KpiTargetNotFoundException("导入批次不存在");
  186. var errors = string.IsNullOrWhiteSpace(batch.ErrorJson)
  187. ? new List<KpiTargetImportErrorDto>()
  188. : JsonSerializer.Deserialize<List<KpiTargetImportErrorDto>>(batch.ErrorJson) ?? new();
  189. var memory = new MemoryStream();
  190. await memory.SaveAsAsync(errors, sheetName: "错误");
  191. memory.Seek(0, SeekOrigin.Begin);
  192. return new FileStreamResult(memory, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
  193. {
  194. FileDownloadName = $"{batch.BatchNo}-errors.xlsx"
  195. };
  196. }
  197. private async Task<KpiTargetImportRow> ParseRowAsync(long tenantId, KpiTargetExcelRow line, int rowNo, long factoryFallback, CancellationToken ct)
  198. {
  199. if (string.IsNullOrWhiteSpace(line.MetricCode))
  200. throw new KpiTargetValidationException("指标编码必填");
  201. if (!decimal.TryParse(line.TargetValue, out var target) || target <= 0)
  202. throw new KpiTargetValidationException("目标值必须大于 0");
  203. if (!DateTime.TryParse(line.EffectiveFrom, out var from))
  204. throw new KpiTargetValidationException("生效日期无效");
  205. DateTime? to = null;
  206. if (!string.IsNullOrWhiteSpace(line.EffectiveTo))
  207. {
  208. if (!DateTime.TryParse(line.EffectiveTo, out var toVal))
  209. throw new KpiTargetValidationException("失效日期无效");
  210. to = toVal.Date;
  211. }
  212. KpiTargetRules.EnsureRange(from, to);
  213. var kpi = await _store.FindKpiAsync(tenantId, line.MetricCode.Trim(), ct)
  214. ?? throw new KpiTargetNotFoundException("指标不存在或不属于当前租户");
  215. _ = kpi;
  216. var factoryId = factoryFallback;
  217. if (!string.IsNullOrWhiteSpace(line.FactoryCode))
  218. {
  219. if (!long.TryParse(line.FactoryCode.Trim(), out factoryId) || factoryId < 0)
  220. throw new KpiTargetValidationException("工厂编码无效");
  221. }
  222. return new KpiTargetImportRow
  223. {
  224. RowNo = rowNo,
  225. MetricCode = line.MetricCode.Trim(),
  226. FactoryId = factoryId,
  227. TargetValue = target,
  228. EffectiveFrom = from.Date,
  229. EffectiveTo = to,
  230. Remark = line.Remark
  231. };
  232. }
  233. private static void DetectFileOverlaps(List<KpiTargetImportRow> valid, List<KpiTargetImportErrorDto> errors)
  234. {
  235. for (var i = 0; i < valid.Count; i++)
  236. {
  237. for (var j = i + 1; j < valid.Count; j++)
  238. {
  239. var a = valid[i];
  240. var b = valid[j];
  241. if (a.MetricCode != b.MetricCode || a.FactoryId != b.FactoryId)
  242. continue;
  243. if (!KpiTargetRules.Overlaps(a.EffectiveFrom, a.EffectiveTo, b.EffectiveFrom, b.EffectiveTo))
  244. continue;
  245. errors.Add(new KpiTargetImportErrorDto
  246. {
  247. RowNo = b.RowNo,
  248. MetricCode = b.MetricCode,
  249. Code = "OVERLAP_FILE",
  250. Message = $"与本文件第 {a.RowNo} 行重叠"
  251. });
  252. }
  253. }
  254. }
  255. private static async Task<string> Sha256Async(Stream stream, CancellationToken ct)
  256. {
  257. using var sha = SHA256.Create();
  258. var hash = await sha.ComputeHashAsync(stream, ct);
  259. return Convert.ToHexString(hash).ToLowerInvariant();
  260. }
  261. }