AdoS0QmsSpecAggregateGuard.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Quality;
  3. namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
  4. /// <summary>
  5. /// 检验规范聚合(主单 + 检验明细)的必填契约守卫。
  6. ///
  7. /// 契约来源:UAT-S0-10 拍板(2026-09-02)。此前四张检规表在前端表单、后端 DTO、DB 列三层
  8. /// 都只约束「文件编号」,导致只填文件编号即可落库空壳检规。本守卫是该契约的唯一实现处,
  9. /// Create / Import 两条写入入口共用,避免再次出现「导入宽于表单」。
  10. ///
  11. /// 两条入口的规则不同,刻意分成两个方法:
  12. /// · 新增(Create / Import)——必填字段必须全部有值,且至少 1 条有内容的检验明细;
  13. /// · 编辑(Update)——「历史兼容、禁止劣化」:历史记录原本就缺的字段允许继续为空,
  14. /// 但原本有值的必填字段不允许被清空;原本 0 明细允许保持 0,原本有明细不允许删空。
  15. ///
  16. /// 【为什么四张检规表的 DTO 上一个 [Required] 都不留】
  17. /// Create 与 Update 共用同一个 UpsertDto。任何 [Required] 都会在 MVC 模型绑定阶段先于本守卫触发,
  18. /// 把 Update 直接挡成 400,从而绕过并破坏 ValidateUpdate(existing, incoming) 的历史兼容语义
  19. /// ——实测 qms_gcjygf 有 3 行 wjbh 为空,正是被遗留的 FileNumber[Required] 锁死不可编辑。
  20. /// 因此「文件编号」的新增必填也统一迁到本守卫,特性层不再承担任何必填职责。
  21. ///
  22. /// 【明细归一化】全空明细是 UI 占位行 / Excel 空行,不是业务明细,一律不落库。
  23. /// Create / Update / Import 三条入口都必须先经 Meaningful(...) 过滤,再用过滤结果做数量校验与写入,
  24. /// 保证「校验看到的」与「写进库的」是同一份数据。
  25. /// </summary>
  26. public static class AdoS0QmsSpecAggregateGuard
  27. {
  28. /// <summary>新增校验。返回 null 表示通过,否则为面向用户的错误文案。</summary>
  29. public static string? ValidateCreate(IReadOnlyList<(string Label, string? Value)> requiredFields, int meaningfulItemCount)
  30. {
  31. foreach (var (label, value) in requiredFields)
  32. {
  33. if (string.IsNullOrWhiteSpace(value)) return $"{label}不能为空";
  34. }
  35. return meaningfulItemCount > 0 ? null : "检验明细至少需要 1 条";
  36. }
  37. /// <summary>编辑校验(历史兼容、禁止劣化)。返回 null 表示通过,否则为面向用户的错误文案。</summary>
  38. public static string? ValidateUpdate(
  39. IReadOnlyList<(string Label, string? Existing, string? Incoming)> requiredFields,
  40. int existingMeaningfulItemCount,
  41. int incomingMeaningfulItemCount)
  42. {
  43. foreach (var (label, existing, incoming) in requiredFields)
  44. {
  45. // 原本为空 → 放行(不强制本次补齐);原本有值 → 不允许清空。
  46. if (!string.IsNullOrWhiteSpace(existing) && string.IsNullOrWhiteSpace(incoming))
  47. return $"{label}原本已有值,不允许清空";
  48. }
  49. // 两侧都按 meaningful 计:历史若本就 0 条有内容的明细,允许继续保持 0。
  50. if (existingMeaningfulItemCount > 0 && incomingMeaningfulItemCount == 0)
  51. return $"该检规原有 {existingMeaningfulItemCount} 条检验明细,不允许全部删除";
  52. return null;
  53. }
  54. // ==================== 明细归一化 ====================
  55. //
  56. // 两个不同的集合,不可混用:
  57. //
  58. // · Meaningful(...) —— 「有业务内容」的明细。用于 Create 的写入集,以及三条入口的**数量校验**。
  59. // · ForUpdateWrite(...) —— Update 的写入集 = Meaningful ∪ 已持久化行(带 Id)。
  60. //
  61. // 为什么 Update 需要单独一个写入集:Update 的明细是 **delete-all + 全量重建** 语义
  62. // (`AsDeleteable().Where(MasterId==id)` 后逐条 Insert,且 SyncXxxEntriesAsync 不回填 item.Id)。
  63. // 若直接拿 Meaningful 去重建,用户只是编辑一次头字段,库里**历史已存在的空明细就会被静默删掉**
  64. // ——实测 aidopdev 有 10 条这样的空明细,分布在 9 张真实业务主单上(如 W00-1-80 的 69 条明细里有 2 条)。
  65. // 本轮不是历史数据清洗批次,因此按 detail identity 区分:
  66. // 带 Id = 库里已有的行 → 即使为空也照原样写回,不主动删除;
  67. // 无 Id = 本次 UI/Excel 新补的空占位 → 丢弃,不新增空记录。
  68. //
  69. // ⚠️ 已知既有局限(非本轮引入):重建会让所有明细拿到新的雪花 Id,明细身份本就不跨 Update 保持。
  70. // 这里只保证**内容**不被顺手清理;若将来要真正保持身份,需另开批次改成按 Id diff 的增量更新。
  71. public static List<AdoS0QmsRawInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsRawInspectionSpecEntryDto> items)
  72. => items.Where(HasValue).ToList();
  73. public static List<AdoS0QmsProcessInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsProcessInspectionSpecEntryDto> items)
  74. => items.Where(HasValue).ToList();
  75. public static List<AdoS0QmsFqcInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsFqcInspectionSpecEntryDto> items)
  76. => items.Where(HasValue).ToList();
  77. public static List<AdoS0QmsOqcInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsOqcInspectionSpecEntryDto> items)
  78. => items.Where(HasValue).ToList();
  79. public static List<AdoS0QmsRawInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsRawInspectionSpecEntryDto> items)
  80. => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
  81. public static List<AdoS0QmsProcessInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsProcessInspectionSpecEntryDto> items)
  82. => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
  83. public static List<AdoS0QmsFqcInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsFqcInspectionSpecEntryDto> items)
  84. => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
  85. public static List<AdoS0QmsOqcInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsOqcInspectionSpecEntryDto> items)
  86. => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
  87. // 实体侧判据:统计库里**现存的 meaningful 明细数**。
  88. // 「原有 ≥1 明细不许删空」必须按 meaningful 计数,否则一张明细全为空的历史主单
  89. // 会因 raw count>0 而永远保存不了(被自己的空占位锁死)。
  90. public static int CountMeaningful(IEnumerable<AdoS0QmsRawInspectionSpecEntry> rows)
  91. => rows.Count(e => e.Seq.HasValue
  92. || AnyNotBlank(e.InspectionItem, e.InspectionStandard, e.InspectionMethod, e.ImageCategory,
  93. e.SamplingScheme, e.Remark, e.Attachment, e.UpperLimit, e.LowerLimit));
  94. public static int CountMeaningful(IEnumerable<AdoS0QmsProcessInspectionSpecEntry> rows)
  95. => rows.Count(e => e.PeelingForce.HasValue
  96. || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
  97. e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit));
  98. public static int CountMeaningful(IEnumerable<AdoS0QmsFqcInspectionSpecEntry> rows)
  99. => rows.Count(e => e.PeelingForce.HasValue
  100. || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
  101. e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit));
  102. public static int CountMeaningful(IEnumerable<AdoS0QmsOqcInspectionSpecEntry> rows)
  103. => rows.Count(e => e.PeelingForce.HasValue
  104. || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
  105. e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit));
  106. // ==================== 明细「有内容」判据 ====================
  107. // 整行全空的明细不计数、不落库,否则前端 openCreate 自动补的那一行空明细
  108. // 会让「≥1 条」形同虚设,并持续制造新的空明细脏数据。
  109. //
  110. // 导入行(ImportRow)的重载与实体侧同语义,只是模板列少了图像分类/备注/附件等非模板列——
  111. // 判据仍集中在本类,导入侧不再自带一份定义。
  112. public static bool HasDetailValue(AdoS0QmsRawInspectionSpecImportRow r)
  113. => r.Seq.HasValue
  114. || AnyNotBlank(r.InspectionItem, r.InspectionStandard, r.InspectionMethod,
  115. r.SamplingScheme, r.UpperLimit, r.LowerLimit);
  116. public static bool HasDetailValue(AdoS0QmsProcessLikeInspectionSpecImportRow r)
  117. => AnyNotBlank(r.OperationCode, r.OperationName, r.InspectionItem, r.InspectionMethod,
  118. r.InspectionSpec, r.InspectionFrequency, r.UpperLimit, r.LowerLimit);
  119. public static bool HasValue(AdoS0QmsRawInspectionSpecEntryDto e)
  120. => e.Seq.HasValue
  121. || AnyNotBlank(e.InspectionItem, e.InspectionStandard, e.InspectionMethod, e.ImageCategory,
  122. e.SamplingScheme, e.Remark, e.Attachment, e.UpperLimit, e.LowerLimit);
  123. public static bool HasValue(AdoS0QmsProcessInspectionSpecEntryDto e)
  124. => e.PeelingForce.HasValue
  125. || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
  126. e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit);
  127. public static bool HasValue(AdoS0QmsFqcInspectionSpecEntryDto e)
  128. => e.PeelingForce.HasValue
  129. || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
  130. e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit);
  131. public static bool HasValue(AdoS0QmsOqcInspectionSpecEntryDto e)
  132. => e.PeelingForce.HasValue
  133. || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
  134. e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit);
  135. // ==================== 成品检验规范(qms_ccjygf / qms_ccjygfzb)====================
  136. // 与上面四组同语义,只是字段集不同:成品检规明细不含工序代号/工序名称/图像分类/检验频次/PeelingForce,
  137. // 改为承载 S7 检验单快照真正消费的列(序号/录入类型/上下限/技术要求/仪器/条款/抽样方案/记录/尺寸规格)。
  138. public static List<AdoS0QmsFinishedInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsFinishedInspectionSpecEntryDto> items)
  139. => items.Where(HasValue).ToList();
  140. public static List<AdoS0QmsFinishedInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsFinishedInspectionSpecEntryDto> items)
  141. => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
  142. public static int CountMeaningful(IEnumerable<AdoS0QmsFinishedInspectionSpecEntry> rows)
  143. => rows.Count(e => AnyNotBlank(e.SeqNo, e.InspectionItem, e.ResultType, e.UpperLimit, e.LowerLimit,
  144. e.TechnicalRequirement, e.InspectionMethod, e.Instrument, e.InspectionClause,
  145. e.TechnicalStandard, e.SamplingPlan, e.Record, e.SizeSpecifications));
  146. public static bool HasValue(AdoS0QmsFinishedInspectionSpecEntryDto e)
  147. => AnyNotBlank(e.SeqNo, e.InspectionItem, e.ResultType, e.UpperLimit, e.LowerLimit,
  148. e.TechnicalRequirement, e.InspectionMethod, e.Instrument, e.InspectionClause,
  149. e.TechnicalStandard, e.SamplingPlan, e.Record, e.SizeSpecifications);
  150. public static bool HasDetailValue(AdoS0QmsFinishedInspectionSpecImportRow r)
  151. => AnyNotBlank(r.SeqNo, r.InspectionItem, r.ResultType, r.UpperLimit, r.LowerLimit,
  152. r.TechnicalRequirement, r.InspectionMethod, r.Instrument,
  153. r.TechnicalStandard, r.SamplingPlan, r.SizeSpecifications);
  154. /// <summary>
  155. /// 成品检规明细的**运行期可用性**校验(S0 侧前置,避免把注定生成不了检验单的检规存进库)。
  156. /// 口径与 `FqcApplyService.GenerateInspection` 的三条兜底一致:
  157. /// ① 检验项目非空;② lrlx 仅 NUMERIC/NON_NUMERIC/空;③ NUMERIC 的上下限须是数值且 下限≤上限。
  158. /// 返回 null 表示通过。
  159. /// </summary>
  160. public static string? ValidateFinishedEntries(IReadOnlyList<AdoS0QmsFinishedInspectionSpecEntryDto> items)
  161. {
  162. foreach (var e in items)
  163. {
  164. if (string.IsNullOrWhiteSpace(e.InspectionItem))
  165. return "存在未填写「检验项目」的明细行,该检规将无法生成检验单";
  166. var type = (e.ResultType ?? string.Empty).Trim().ToUpperInvariant();
  167. if (type.Length > 0 && type != "NUMERIC" && type != "NON_NUMERIC")
  168. return $"检验项目「{e.InspectionItem}」的录入类型非法,只能是 NUMERIC 或 NON_NUMERIC";
  169. var upper = (e.UpperLimit ?? string.Empty).Trim();
  170. var lower = (e.LowerLimit ?? string.Empty).Trim();
  171. var hasLimit = upper.Length > 0 || lower.Length > 0;
  172. if (type == "NON_NUMERIC" && hasLimit)
  173. return $"检验项目「{e.InspectionItem}」是非数值型,不能配置上下限";
  174. if (!hasLimit) continue;
  175. if (!decimal.TryParse(upper, out var u) && upper.Length > 0)
  176. return $"检验项目「{e.InspectionItem}」的上限必须是数值";
  177. if (!decimal.TryParse(lower, out var l) && lower.Length > 0)
  178. return $"检验项目「{e.InspectionItem}」的下限必须是数值";
  179. if (upper.Length > 0 && lower.Length > 0 && l > u)
  180. return $"检验项目「{e.InspectionItem}」的下限不能大于上限";
  181. }
  182. return null;
  183. }
  184. private static bool AnyNotBlank(params string?[] values)
  185. {
  186. foreach (var v in values)
  187. {
  188. if (!string.IsNullOrWhiteSpace(v)) return true;
  189. }
  190. return false;
  191. }
  192. }