S5IqcSpecResolver.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. using System.Globalization;
  2. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  3. /// <summary>S5 IQC 检规解析结果状态。</summary>
  4. public static class S5IqcSpecResolveStatus
  5. {
  6. /// <summary>唯一命中(AUTO EXACT,且生效日期可解析并已生效)。</summary>
  7. public const string Matched = "MATCHED";
  8. /// <summary>唯一命中人工绑定(MANUAL 优先于 AUTO)。</summary>
  9. public const string MatchedManual = "MATCHED_MANUAL";
  10. /// <summary>唯一命中,但生效日期为空/不可解析。仍固化,因 S5 历史 sxrq 未治理。</summary>
  11. public const string MatchedUndated = "MATCHED_UNDATED";
  12. /// <summary>该物料无任何检规映射。合法业务状态(如免检料),不阻断生成。</summary>
  13. public const string NoSpec = "NO_SPEC";
  14. /// <summary>有候选,但全部生效日期晚于参考时点(仅未来版本)。不把未来检规当作当前检规。</summary>
  15. public const string NoEffectiveSpec = "NO_EFFECTIVE_SPEC";
  16. /// <summary>多个互斥候选,无业务依据择一。不瞎选、不阻断,四列留空。</summary>
  17. public const string Ambiguous = "AMBIGUOUS";
  18. }
  19. /// <summary>Resolver 候选行(来自桥表 ⋈ qms_jygf,均已按租户双重过滤)。</summary>
  20. public class S5IqcSpecCandidate
  21. {
  22. public long SpecId { get; set; }
  23. /// <summary>检规文件编号 wjbh。</summary>
  24. public string? SpecNo { get; set; }
  25. /// <summary>检规版本 bb。</summary>
  26. public string? SpecVersion { get; set; }
  27. /// <summary>生效日期原始文本 sxrq(varchar,可能为 NULL / 'AUTODED' 等不可解析值)。</summary>
  28. public string? EffectiveDateRaw { get; set; }
  29. /// <summary>是否人工绑定行。</summary>
  30. public bool IsManual { get; set; }
  31. /// <summary>归一化后的生效日期;不可解析为 null。由 Decide 填充。</summary>
  32. public DateTime? NormalizedEffective { get; set; }
  33. }
  34. /// <summary>Resolver 返回模型(最小集,YAGNI:不返回完整候选列表)。</summary>
  35. public class S5IqcSpecResolveResult
  36. {
  37. public string Status { get; set; } = S5IqcSpecResolveStatus.NoSpec;
  38. public long? SpecId { get; set; }
  39. public string? SpecNo { get; set; }
  40. public string? SpecVersion { get; set; }
  41. /// <summary>生效日期**原始文本**,原样固化进 jgsxrq(不写归一化值,避免信息损失)。</summary>
  42. public string? EffectiveDateRaw { get; set; }
  43. public int CandidateCount { get; set; }
  44. public string? Warning { get; set; }
  45. /// <summary>是否命中可固化的检规(三个 MATCHED* 状态之一)。</summary>
  46. public bool IsMatched =>
  47. Status is S5IqcSpecResolveStatus.Matched
  48. or S5IqcSpecResolveStatus.MatchedManual
  49. or S5IqcSpecResolveStatus.MatchedUndated;
  50. }
  51. /// <summary>
  52. /// S5 IQC 原材料检规解析器(B-2)。
  53. ///
  54. /// 输入 (tenantId, materialCode, referenceTime) → 唯一可固化检规 或 明确的不可解析原因。
  55. ///
  56. /// 【硬边界】
  57. /// ① 候选唯一入口是桥表 <c>ado_s5_iqc_spec_material_map</c>,**绝不在运行期解析 qms_jygf.wlbm**;
  58. /// ② 物料等值匹配,禁 LIKE;
  59. /// ③ 租户双重过滤:<c>map.tenant_id</c> 与 <c>spec.tenant_id</c> 同时约束,不得只信桥表 spec_id;
  60. /// ④ 禁止无排序 FirstOrDefault、禁止用 specId 大小或版本字符串字典序决定业务结果;
  61. /// ⑤ **Resolver 只读,且只允许在 GenerateInspBill 调用**——detail / list / flow 路径实时 resolve
  62. /// 会把"今天的检规"显示到历史检验单上,破坏快照语义(B-2 §17)。
  63. ///
  64. /// 【与 S6 的差异(刻意)】S6 <c>S6ProcessSpecResolver</c> 要求 sxrj 可解析且明细行数&gt;0,否则
  65. /// INVALID_SPEC。S5 两条都放宽:S5 的报检分录是 165 贴源既成事实,Ai-DOP 无权因检规配置不全
  66. /// 而拒绝为其建检验单;且实测 qms_jygf 4712 行中 428 行(9.1%) sxrq 不可解析、UAT-A 8 条检规中
  67. /// 7 条明细为 0。Resolver 是 enrichment,不是 hard validation gate。
  68. /// </summary>
  69. public class S5IqcSpecResolver : ITransient
  70. {
  71. private readonly ISqlSugarClient _db;
  72. public S5IqcSpecResolver(ISqlSugarClient db) => _db = db;
  73. /// <summary>
  74. /// 解析物料对应的唯一可固化检规。
  75. /// </summary>
  76. /// <param name="tenantId">严格可信租户(调用方已解析)。</param>
  77. /// <param name="materialCode">报检分录 FMATERIALCFG。</param>
  78. /// <param name="referenceTime">
  79. /// 参考时点。口径对齐 S6(<c>S6ProcessInspectionService.cs:66</c>「ReferenceDate 取检验时点(now)」):
  80. /// 取**检验单生成时刻**,即"生成时点当前有效"的检规版本。
  81. /// </param>
  82. public async Task<S5IqcSpecResolveResult> ResolveAsync(long tenantId, string? materialCode, DateTime referenceTime)
  83. {
  84. if (tenantId <= 0 || string.IsNullOrWhiteSpace(materialCode))
  85. {
  86. return new S5IqcSpecResolveResult
  87. {
  88. Status = S5IqcSpecResolveStatus.NoSpec,
  89. Warning = "报检分录缺物料编码,无法解析来料检验规范。"
  90. };
  91. }
  92. var normalized = S5IqcMaterialTokenizer.Normalize(materialCode);
  93. // 租户双重过滤:桥表与检规主表分别显式约束,绝不仅凭 map.spec_id 反查。
  94. var candidates = await _db.Ado.SqlQueryAsync<S5IqcSpecCandidate>(
  95. """
  96. SELECT g.id AS SpecId, g.wjbh AS SpecNo, g.bb AS SpecVersion,
  97. g.sxrq AS EffectiveDateRaw, m.is_manual AS IsManual
  98. FROM ado_s5_iqc_spec_material_map m
  99. JOIN qms_jygf g ON g.id = m.spec_id AND g.tenant_id = @TenantId
  100. WHERE m.tenant_id = @TenantId
  101. AND m.material_code = @MaterialCode
  102. AND m.match_status IN ('EXACT','MANUAL')
  103. """,
  104. new List<SugarParameter>
  105. {
  106. new("@TenantId", tenantId),
  107. new("@MaterialCode", normalized),
  108. });
  109. return Decide(candidates, referenceTime);
  110. }
  111. /// <summary>
  112. /// 纯裁决逻辑(无 DB,便于单测)。对齐 S6 <c>S6ProcessSpecResolver.Decide</c> 的可测形态。
  113. /// </summary>
  114. public static S5IqcSpecResolveResult Decide(IReadOnlyList<S5IqcSpecCandidate>? candidates, DateTime referenceTime)
  115. {
  116. var all = (candidates ?? Array.Empty<S5IqcSpecCandidate>()).ToList();
  117. if (all.Count == 0)
  118. {
  119. return new S5IqcSpecResolveResult
  120. {
  121. Status = S5IqcSpecResolveStatus.NoSpec,
  122. CandidateCount = 0,
  123. Warning = "当前物料未配置来料检验规范(可能为免检料)。"
  124. };
  125. }
  126. foreach (var c in all) c.NormalizedEffective = NormalizeDate(c.EffectiveDateRaw);
  127. // ── ① MANUAL 优先:人工绑定既然存在,AUTO 不得覆盖它 ──
  128. var manual = all.Where(c => c.IsManual).ToList();
  129. if (manual.Count > 0)
  130. {
  131. var manualSpecIds = manual.Select(c => c.SpecId).Distinct().ToList();
  132. if (manualSpecIds.Count == 1)
  133. {
  134. var win = manual[0];
  135. return Hit(win, S5IqcSpecResolveStatus.MatchedManual, manual.Count);
  136. }
  137. return new S5IqcSpecResolveResult
  138. {
  139. Status = S5IqcSpecResolveStatus.Ambiguous,
  140. CandidateCount = manualSpecIds.Count,
  141. Warning = $"该物料存在 {manualSpecIds.Count} 条互斥的人工绑定检规,请在 S0 订正后重试;本单未固化检规。"
  142. };
  143. }
  144. // ── ② AUTO EXACT ──
  145. var auto = all.Where(c => !c.IsManual).ToList();
  146. var distinctSpecIds = auto.Select(c => c.SpecId).Distinct().ToList();
  147. if (distinctSpecIds.Count == 0)
  148. {
  149. return new S5IqcSpecResolveResult
  150. {
  151. Status = S5IqcSpecResolveStatus.NoSpec,
  152. CandidateCount = 0,
  153. Warning = "当前物料未配置来料检验规范。"
  154. };
  155. }
  156. // 单候选:即使 sxrq 为空/不可解析也允许固化(B-2 §七 Case 1)
  157. if (distinctSpecIds.Count == 1)
  158. {
  159. var only = auto[0];
  160. var status = only.NormalizedEffective == null
  161. ? S5IqcSpecResolveStatus.MatchedUndated
  162. : (only.NormalizedEffective.Value.Date <= referenceTime.Date
  163. ? S5IqcSpecResolveStatus.Matched
  164. // 单候选且生效日期在未来:不把未来检规当作当前检规
  165. : S5IqcSpecResolveStatus.NoEffectiveSpec);
  166. if (status == S5IqcSpecResolveStatus.NoEffectiveSpec)
  167. {
  168. return new S5IqcSpecResolveResult
  169. {
  170. Status = status,
  171. CandidateCount = 1,
  172. Warning = $"该物料唯一检规 {only.SpecNo}/{only.SpecVersion} 生效日期 {only.EffectiveDateRaw} 晚于当前时点,本单未固化检规。"
  173. };
  174. }
  175. return Hit(only, status, 1);
  176. }
  177. // ── ③ 多候选:先看是否跨不同 wjbh(检规编号) ──
  178. // 跨 wjbh = 两份不同的检规文件同时匹配该物料,无业务依据择一 → AMBIGUOUS(核心防误配门)
  179. var wjbhGroups = auto
  180. .GroupBy(c => (c.SpecNo ?? string.Empty).Trim(), StringComparer.OrdinalIgnoreCase)
  181. .ToList();
  182. if (wjbhGroups.Count > 1)
  183. {
  184. return new S5IqcSpecResolveResult
  185. {
  186. Status = S5IqcSpecResolveStatus.Ambiguous,
  187. CandidateCount = distinctSpecIds.Count,
  188. Warning = $"该物料同时匹配 {wjbhGroups.Count} 个不同检规编号({string.Join(" / ", wjbhGroups.Select(g => g.Key))}),"
  189. + "无法自动判定,请在 S0 订正检规物料范围或人工绑定唯一检规;本单未固化检规。"
  190. };
  191. }
  192. // ── ④ 同一 wjbh 的多版本:按生效日期裁决(A > B > C) ──
  193. var dated = auto.Where(c => c.NormalizedEffective != null).ToList();
  194. var effective = dated.Where(c => c.NormalizedEffective!.Value.Date <= referenceTime.Date).ToList();
  195. var undated = auto.Where(c => c.NormalizedEffective == null).ToList();
  196. // A. 已生效且日期可解析 → 取最大生效日期
  197. if (effective.Count > 0)
  198. {
  199. var maxDate = effective.Max(c => c.NormalizedEffective!.Value.Date);
  200. var top = effective.Where(c => c.NormalizedEffective!.Value.Date == maxDate).ToList();
  201. var topSpecIds = top.Select(c => c.SpecId).Distinct().ToList();
  202. if (topSpecIds.Count == 1) return Hit(top[0], S5IqcSpecResolveStatus.Matched, distinctSpecIds.Count);
  203. // 同一最大生效日期仍对应多个 specId → 不再靠 id 排序决定业务结果
  204. return new S5IqcSpecResolveResult
  205. {
  206. Status = S5IqcSpecResolveStatus.Ambiguous,
  207. CandidateCount = topSpecIds.Count,
  208. Warning = $"检规 {top[0].SpecNo} 存在 {topSpecIds.Count} 个生效日期相同({maxDate:yyyy-MM-dd})的版本,无法自动判定;本单未固化检规。"
  209. };
  210. }
  211. // B. 无已生效版本,但有恰好 1 条未标注日期 → 允许
  212. if (undated.Count > 0)
  213. {
  214. var undatedSpecIds = undated.Select(c => c.SpecId).Distinct().ToList();
  215. if (undatedSpecIds.Count == 1) return Hit(undated[0], S5IqcSpecResolveStatus.MatchedUndated, distinctSpecIds.Count);
  216. return new S5IqcSpecResolveResult
  217. {
  218. Status = S5IqcSpecResolveStatus.Ambiguous,
  219. CandidateCount = undatedSpecIds.Count,
  220. Warning = $"检规 {undated[0].SpecNo} 有 {undatedSpecIds.Count} 个版本均未标注生效日期,无业务依据判断哪个有效;本单未固化检规。"
  221. };
  222. }
  223. // C. 只剩未来版本
  224. return new S5IqcSpecResolveResult
  225. {
  226. Status = S5IqcSpecResolveStatus.NoEffectiveSpec,
  227. CandidateCount = distinctSpecIds.Count,
  228. Warning = $"检规 {auto[0].SpecNo} 的全部版本生效日期均晚于当前时点,本单未固化检规。"
  229. };
  230. }
  231. private static S5IqcSpecResolveResult Hit(S5IqcSpecCandidate c, string status, int candidateCount) => new()
  232. {
  233. Status = status,
  234. SpecId = c.SpecId,
  235. SpecNo = c.SpecNo,
  236. SpecVersion = c.SpecVersion,
  237. EffectiveDateRaw = c.EffectiveDateRaw,
  238. CandidateCount = candidateCount,
  239. };
  240. /// <summary>
  241. /// 归一化 qms_jygf.sxrq 自由文本为日期。支持 2021.07.13 / 2021-07-13 / 2021/07/13 /
  242. /// 带时间后缀等;'AUTODED' 等非日期与 NULL 返回 null。
  243. ///
  244. /// 口径与 <c>S6ProcessSpecResolver.NormalizeDate</c> 一致,此处**刻意复制而非跨模块引用**:
  245. /// CLAUDE.md 第九节·三要求模块间不直接依赖对方目录的服务类,S5 不应 using S6 的 Manufacturing 命名空间。
  246. /// </summary>
  247. public static DateTime? NormalizeDate(string? raw)
  248. {
  249. if (string.IsNullOrWhiteSpace(raw)) return null;
  250. var s = raw.Trim().Replace('.', '-').Replace('/', '-');
  251. var sp = s.IndexOf(' ');
  252. if (sp > 0) s = s[..sp];
  253. if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) return d;
  254. if (DateTime.TryParseExact(s, new[] { "yyyy-M-d", "yyyy-MM-dd" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out d)) return d;
  255. return null;
  256. }
  257. }