AdoS0QmsSpecAggregateGuardTests.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. using Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
  2. using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
  3. using Admin.NET.Plugin.AiDOP.Entity.S0.Quality;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S0.Quality;
  6. /// <summary>
  7. /// 检验规范聚合必填契约(UAT-S0-10 拍板,2026-09-02)。
  8. /// 新增严格 / 编辑「历史兼容、禁止劣化」是两套规则,分别覆盖。
  9. /// </summary>
  10. public class AdoS0QmsSpecAggregateGuardTests
  11. {
  12. private static (string, string?)[] RawFields(string? file, string? mat, string? name, string? ver) =>
  13. [("文件编号", file), ("物料编码", mat), ("原材料名称", name), ("版本", ver)];
  14. // ==================== 新增 ====================
  15. [Fact]
  16. public void Create_WhenAllRequiredPresentAndHasItem_Passes()
  17. {
  18. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), 1);
  19. Assert.Null(error);
  20. }
  21. /// <summary>UAT-S0-10 原始场景:只填文件编号、其余必填全空。</summary>
  22. [Fact]
  23. public void Create_WhenOnlyFileNumberFilled_IsRejected()
  24. {
  25. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("UATA-BAD-SPEC", null, null, null), 0);
  26. Assert.Equal("物料编码不能为空", error);
  27. }
  28. [Theory]
  29. [InlineData(null)]
  30. [InlineData("")]
  31. [InlineData(" ")]
  32. public void Create_TreatsWhitespaceAsMissing(string? blank)
  33. {
  34. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", blank, "V1"), 1);
  35. Assert.Equal("原材料名称不能为空", error);
  36. }
  37. /// <summary>头字段齐全但 0 条明细,仍必须拒绝——Create / Import 两条入口共用本规则。</summary>
  38. [Fact]
  39. public void Create_WhenNoDetail_IsRejected()
  40. {
  41. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), 0);
  42. Assert.Equal("检验明细至少需要 1 条", error);
  43. }
  44. // ==================== 编辑:历史兼容 ====================
  45. [Fact]
  46. public void Update_WhenHistoricalFieldWasAlreadyBlank_AllowsStayingBlank()
  47. {
  48. // 实测 aidopdev 有 72 条历史记录缺必填字段;它们必须仍可编辑其他字段。
  49. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  50. [("物料编码", null, null), ("版本", "V1", "V2")], existingMeaningfulItemCount: 3, incomingMeaningfulItemCount: 3);
  51. Assert.Null(error);
  52. }
  53. [Fact]
  54. public void Update_WhenHistoricalRecordHadNoDetail_AllowsStayingZero()
  55. {
  56. // 实测 22 条历史记录 0 明细。
  57. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  58. [("物料编码", "M-001", "M-001")], existingMeaningfulItemCount: 0, incomingMeaningfulItemCount: 0);
  59. Assert.Null(error);
  60. }
  61. [Fact]
  62. public void Update_WhenHistoricalBlankFieldGetsFilled_Passes()
  63. {
  64. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  65. [("物料编码", "", "M-001")], existingMeaningfulItemCount: 1, incomingMeaningfulItemCount: 1);
  66. Assert.Null(error);
  67. }
  68. // ==================== 编辑:禁止劣化 ====================
  69. [Fact]
  70. public void Update_WhenClearingAPopulatedRequiredField_IsRejected()
  71. {
  72. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  73. [("物料编码", "M-001", " ")], existingMeaningfulItemCount: 1, incomingMeaningfulItemCount: 1);
  74. Assert.Equal("物料编码原本已有值,不允许清空", error);
  75. }
  76. [Fact]
  77. public void Update_WhenDeletingAllExistingDetails_IsRejected()
  78. {
  79. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  80. [("物料编码", "M-001", "M-001")], existingMeaningfulItemCount: 5, incomingMeaningfulItemCount: 0);
  81. Assert.Equal("该检规原有 5 条检验明细,不允许全部删除", error);
  82. }
  83. // ==================== 文件编号:必填已从 [Required] 迁入守卫 ====================
  84. /// <summary>阶段 1.1 · 场景 1:历史 FileNumber 为空的记录,改其他字段必须能存。</summary>
  85. [Fact]
  86. public void Update_WhenHistoricalFileNumberIsNull_AllowsEditingOtherFields()
  87. {
  88. // 对应实测的 3 行 qms_gcjygf.wjbh IS NULL。
  89. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  90. [
  91. ("文件编号", null, null),
  92. ("物料编码", "M-001", "M-002"),
  93. ("版本", "V1", "V1")
  94. ],
  95. existingMeaningfulItemCount: 2, incomingMeaningfulItemCount: 2);
  96. Assert.Null(error);
  97. }
  98. /// <summary>阶段 1.1 · 场景 2:原本有文件编号,Update 清空必须拒绝。</summary>
  99. [Fact]
  100. public void Update_WhenClearingAnExistingFileNumber_IsRejected()
  101. {
  102. var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  103. [("文件编号", "F-001", "")], existingMeaningfulItemCount: 1, incomingMeaningfulItemCount: 1);
  104. Assert.Equal("文件编号原本已有值,不允许清空", error);
  105. }
  106. [Fact]
  107. public void Create_WhenFileNumberMissing_IsRejected()
  108. {
  109. var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields(null, "M-001", "钢板", "V1"), 1);
  110. Assert.Equal("文件编号不能为空", error);
  111. }
  112. // ==================== 明细归一化:全空占位不落库 ====================
  113. /// <summary>阶段 1.1 · 场景 3:1 实 + 1 空 → 只保留 1 条真实明细。</summary>
  114. [Fact]
  115. public void Meaningful_DropsBlankPlaceholderRows_KeepingOnlyRealDetails()
  116. {
  117. var incoming = new List<AdoS0QmsRawInspectionSpecEntryDto>
  118. {
  119. new() { InspectionItem = "外观", UpperLimit = "10" },
  120. new() { InspectionItem = "", InspectionStandard = " ", UpperLimit = null }, // 前端 addItem 的空占位
  121. };
  122. var kept = AdoS0QmsSpecAggregateGuard.Meaningful(incoming);
  123. Assert.Single(kept);
  124. Assert.Equal("外观", kept[0].InspectionItem);
  125. // 过滤结果同时用于数量校验,保证「校验看到的」与「写进库的」一致
  126. Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), kept.Count));
  127. }
  128. /// <summary>阶段 1.1 · 场景 4:明细全为空占位 → Create 按 0 明细拒绝。</summary>
  129. [Fact]
  130. public void Create_WhenAllDetailsAreBlankPlaceholders_IsRejected()
  131. {
  132. var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsRawInspectionSpecEntryDto> { new(), new() });
  133. Assert.Empty(kept);
  134. Assert.Equal("检验明细至少需要 1 条",
  135. AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), kept.Count));
  136. }
  137. /// <summary>阶段 1.1 · 场景 5:历史 0 明细 + UI 空占位 → 放行,且不得写出任何明细。</summary>
  138. [Fact]
  139. public void Update_WhenHistoricalHadNoDetailAndUiAddsBlankPlaceholder_WritesNothing()
  140. {
  141. // UI 新补的空占位没有 Id → 不进写入集
  142. var written = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(new List<AdoS0QmsProcessInspectionSpecEntryDto> { new() });
  143. Assert.Empty(written); // 写库用的就是这份 → 不会新增全空明细
  144. Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  145. [("物料编码", "M-001", "M-001")], existingMeaningfulItemCount: 0, incomingMeaningfulItemCount: 0));
  146. }
  147. /// <summary>
  148. /// 阶段 1.1 · 场景 10:Update 是 delete-all + 全量重建语义,
  149. /// 因此写入集必须保留**历史已持久化**的空明细(带 Id),否则用户编辑一次头字段
  150. /// 就会静默删掉库里的空行——实测 aidopdev 有 10 条这样的行,分布在 9 张真实主单上。
  151. /// </summary>
  152. [Fact]
  153. public void Update_KeepsPersistedEmptyDetail_ButDropsNewEmptyPlaceholder()
  154. {
  155. var incoming = new List<AdoS0QmsRawInspectionSpecEntryDto>
  156. {
  157. new() { Id = 101, InspectionItem = "外观" }, // 历史真实明细
  158. new() { Id = 102 }, // 历史已持久化的空明细 → 必须保留
  159. new() { InspectionItem = " " }, // 本次 UI 新补的空占位 → 必须丢弃
  160. };
  161. var written = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(incoming);
  162. Assert.Equal(2, written.Count);
  163. Assert.Equal(new long?[] { 101, 102 }, written.Select(x => x.Id).ToArray());
  164. // 数量校验仍只认 meaningful:这里只有 1 条有内容
  165. Assert.Single(AdoS0QmsSpecAggregateGuard.Meaningful(incoming));
  166. }
  167. /// <summary>Create 侧相反:任何空明细都没有 Id,一律不落库。</summary>
  168. [Fact]
  169. public void Create_NeverPersistsEmptyDetail_EvenIfClientSuppliesAnId()
  170. {
  171. var incoming = new List<AdoS0QmsRawInspectionSpecEntryDto> { new() { InspectionItem = "外观" }, new() { Id = 999 } };
  172. // Create 走 Meaningful,不走 ForUpdateWrite → 带 Id 的空行同样不落库
  173. Assert.Single(AdoS0QmsSpecAggregateGuard.Meaningful(incoming));
  174. }
  175. /// <summary>
  176. /// 「原有 ≥1 明细不许删空」必须按 meaningful 计。
  177. /// 若按 raw count,一张明细全为空的历史主单会被自己的空占位永久锁死、无法保存。
  178. /// </summary>
  179. [Fact]
  180. public void Update_WhenAllExistingDetailsAreEmpty_IsNotLockedOut()
  181. {
  182. var existingRows = new List<AdoS0QmsRawInspectionSpecEntry> { new(), new() }; // 2 条空行
  183. var existingMeaningful = AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows);
  184. Assert.Equal(0, existingMeaningful);
  185. Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  186. [("物料编码", "M-001", "M-001")], existingMeaningful, incomingMeaningfulItemCount: 0));
  187. }
  188. [Fact]
  189. public void CountMeaningful_OnEntities_MatchesDtoSemantics()
  190. {
  191. Assert.Equal(1, AdoS0QmsSpecAggregateGuard.CountMeaningful(new List<AdoS0QmsRawInspectionSpecEntry>
  192. {
  193. new() { InspectionItem = "外观" }, new() { InspectionItem = " " }, new()
  194. }));
  195. Assert.Equal(2, AdoS0QmsSpecAggregateGuard.CountMeaningful(new List<AdoS0QmsProcessInspectionSpecEntry>
  196. {
  197. new() { OperationCode = "OP10" }, new() { PeelingForce = 5 }, new()
  198. }));
  199. }
  200. /// <summary>阶段 1.1 · 场景 6:原有真实明细被删到只剩空占位 → 按「删到 0」拒绝。</summary>
  201. [Fact]
  202. public void Update_WhenRealDetailsReducedToBlankPlaceholderOnly_IsRejected()
  203. {
  204. var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsFqcInspectionSpecEntryDto> { new() });
  205. Assert.Empty(kept);
  206. Assert.Equal("该检规原有 3 条检验明细,不允许全部删除",
  207. AdoS0QmsSpecAggregateGuard.ValidateUpdate(
  208. [("物料编码", "M-001", "M-001")], existingMeaningfulItemCount: 3, incomingMeaningfulItemCount: kept.Count));
  209. }
  210. /// <summary>阶段 1.1 · 场景 7:导入侧口径与实体侧同源——空明细行不落库,全空 aggregate 拒绝。</summary>
  211. [Fact]
  212. public void Import_BlankDetailRowsAreNotCountedAndAllBlankAggregateIsRejected()
  213. {
  214. var headerOnly = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001", MaterialCode = "M-001", RawMaterialName = "钢板", VersionNo = "V1" };
  215. var blankDetail = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001" };
  216. var realDetail = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001", InspectionItem = "外观" };
  217. Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(headerOnly));
  218. Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(blankDetail));
  219. Assert.True(AdoS0QmsSpecAggregateGuard.HasDetailValue(realDetail));
  220. // 只有表头行 + 空行的分组 → 0 条明细 → 整份拒绝
  221. var group = new[] { headerOnly, blankDetail };
  222. Assert.Equal("检验明细至少需要 1 条",
  223. AdoS0QmsSpecAggregateGuard.ValidateCreate(
  224. RawFields("F-001", "M-001", "钢板", "V1"),
  225. group.Count(AdoS0QmsSpecAggregateGuard.HasDetailValue)));
  226. }
  227. [Fact]
  228. public void Import_ProcessLikeBlankRowIsNotADetail()
  229. {
  230. Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(
  231. new AdoS0QmsProcessLikeInspectionSpecImportRow { FileNumber = "F-001", MaterialCode = "M-001", VersionNo = "V1" }));
  232. Assert.True(AdoS0QmsSpecAggregateGuard.HasDetailValue(
  233. new AdoS0QmsProcessLikeInspectionSpecImportRow { FileNumber = "F-001", OperationCode = "OP10" }));
  234. }
  235. // ==================== 明细「有内容」判据 ====================
  236. [Fact]
  237. public void HasValue_WhenRawEntryIsEntirelyBlank_ReturnsFalse()
  238. {
  239. // 前端 openCreate 会自动补一行空明细;它不得让「≥1 条」形同虚设。
  240. Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto
  241. {
  242. InspectionItem = "", InspectionStandard = " ", UpperLimit = null
  243. }));
  244. }
  245. [Fact]
  246. public void HasValue_WhenRawEntryHasAnyContent_ReturnsTrue()
  247. {
  248. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { InspectionItem = "外观" }));
  249. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { Seq = 1 }));
  250. }
  251. [Fact]
  252. public void HasValue_WhenProcessLikeEntryIsEntirelyBlank_ReturnsFalse()
  253. {
  254. Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsProcessInspectionSpecEntryDto()));
  255. Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsFqcInspectionSpecEntryDto()));
  256. Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsOqcInspectionSpecEntryDto()));
  257. }
  258. [Fact]
  259. public void HasValue_WhenProcessLikeEntryHasAnyContent_ReturnsTrue()
  260. {
  261. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsProcessInspectionSpecEntryDto { OperationCode = "OP10" }));
  262. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsFqcInspectionSpecEntryDto { InspectionItem = "尺寸" }));
  263. Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsOqcInspectionSpecEntryDto { PeelingForce = 5 }));
  264. }
  265. }