using System.Globalization; namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse; /// S5 IQC 检规解析结果状态。 public static class S5IqcSpecResolveStatus { /// 唯一命中(AUTO EXACT,且生效日期可解析并已生效)。 public const string Matched = "MATCHED"; /// 唯一命中人工绑定(MANUAL 优先于 AUTO)。 public const string MatchedManual = "MATCHED_MANUAL"; /// 唯一命中,但生效日期为空/不可解析。仍固化,因 S5 历史 sxrq 未治理。 public const string MatchedUndated = "MATCHED_UNDATED"; /// 该物料无任何检规映射。合法业务状态(如免检料),不阻断生成。 public const string NoSpec = "NO_SPEC"; /// 有候选,但全部生效日期晚于参考时点(仅未来版本)。不把未来检规当作当前检规。 public const string NoEffectiveSpec = "NO_EFFECTIVE_SPEC"; /// 多个互斥候选,无业务依据择一。不瞎选、不阻断,四列留空。 public const string Ambiguous = "AMBIGUOUS"; } /// Resolver 候选行(来自桥表 ⋈ qms_jygf,均已按租户双重过滤)。 public class S5IqcSpecCandidate { public long SpecId { get; set; } /// 检规文件编号 wjbh。 public string? SpecNo { get; set; } /// 检规版本 bb。 public string? SpecVersion { get; set; } /// 生效日期原始文本 sxrq(varchar,可能为 NULL / 'AUTODED' 等不可解析值)。 public string? EffectiveDateRaw { get; set; } /// 是否人工绑定行。 public bool IsManual { get; set; } /// 归一化后的生效日期;不可解析为 null。由 Decide 填充。 public DateTime? NormalizedEffective { get; set; } } /// Resolver 返回模型(最小集,YAGNI:不返回完整候选列表)。 public class S5IqcSpecResolveResult { public string Status { get; set; } = S5IqcSpecResolveStatus.NoSpec; public long? SpecId { get; set; } public string? SpecNo { get; set; } public string? SpecVersion { get; set; } /// 生效日期**原始文本**,原样固化进 jgsxrq(不写归一化值,避免信息损失)。 public string? EffectiveDateRaw { get; set; } public int CandidateCount { get; set; } public string? Warning { get; set; } /// 是否命中可固化的检规(三个 MATCHED* 状态之一)。 public bool IsMatched => Status is S5IqcSpecResolveStatus.Matched or S5IqcSpecResolveStatus.MatchedManual or S5IqcSpecResolveStatus.MatchedUndated; } /// /// S5 IQC 原材料检规解析器(B-2)。 /// /// 输入 (tenantId, materialCode, referenceTime) → 唯一可固化检规 或 明确的不可解析原因。 /// /// 【硬边界】 /// ① 候选唯一入口是桥表 ado_s5_iqc_spec_material_map,**绝不在运行期解析 qms_jygf.wlbm**; /// ② 物料等值匹配,禁 LIKE; /// ③ 租户双重过滤:map.tenant_idspec.tenant_id 同时约束,不得只信桥表 spec_id; /// ④ 禁止无排序 FirstOrDefault、禁止用 specId 大小或版本字符串字典序决定业务结果; /// ⑤ **Resolver 只读,且只允许在 GenerateInspBill 调用**——detail / list / flow 路径实时 resolve /// 会把"今天的检规"显示到历史检验单上,破坏快照语义(B-2 §17)。 /// /// 【与 S6 的差异(刻意)】S6 S6ProcessSpecResolver 要求 sxrj 可解析且明细行数>0,否则 /// INVALID_SPEC。S5 两条都放宽:S5 的报检分录是 165 贴源既成事实,Ai-DOP 无权因检规配置不全 /// 而拒绝为其建检验单;且实测 qms_jygf 4712 行中 428 行(9.1%) sxrq 不可解析、UAT-A 8 条检规中 /// 7 条明细为 0。Resolver 是 enrichment,不是 hard validation gate。 /// public class S5IqcSpecResolver : ITransient { private readonly ISqlSugarClient _db; public S5IqcSpecResolver(ISqlSugarClient db) => _db = db; /// /// 解析物料对应的唯一可固化检规。 /// /// 严格可信租户(调用方已解析)。 /// 报检分录 FMATERIALCFG。 /// /// 参考时点。口径对齐 S6(S6ProcessInspectionService.cs:66「ReferenceDate 取检验时点(now)」): /// 取**检验单生成时刻**,即"生成时点当前有效"的检规版本。 /// public async Task ResolveAsync(long tenantId, string? materialCode, DateTime referenceTime) { if (tenantId <= 0 || string.IsNullOrWhiteSpace(materialCode)) { return new S5IqcSpecResolveResult { Status = S5IqcSpecResolveStatus.NoSpec, Warning = "报检分录缺物料编码,无法解析来料检验规范。" }; } var normalized = S5IqcMaterialTokenizer.Normalize(materialCode); // 租户双重过滤:桥表与检规主表分别显式约束,绝不仅凭 map.spec_id 反查。 var candidates = await _db.Ado.SqlQueryAsync( """ SELECT g.id AS SpecId, g.wjbh AS SpecNo, g.bb AS SpecVersion, g.sxrq AS EffectiveDateRaw, m.is_manual AS IsManual FROM ado_s5_iqc_spec_material_map m JOIN qms_jygf g ON g.id = m.spec_id AND g.tenant_id = @TenantId WHERE m.tenant_id = @TenantId AND m.material_code = @MaterialCode AND m.match_status IN ('EXACT','MANUAL') """, new List { new("@TenantId", tenantId), new("@MaterialCode", normalized), }); return Decide(candidates, referenceTime); } /// /// 纯裁决逻辑(无 DB,便于单测)。对齐 S6 S6ProcessSpecResolver.Decide 的可测形态。 /// public static S5IqcSpecResolveResult Decide(IReadOnlyList? candidates, DateTime referenceTime) { var all = (candidates ?? Array.Empty()).ToList(); if (all.Count == 0) { return new S5IqcSpecResolveResult { Status = S5IqcSpecResolveStatus.NoSpec, CandidateCount = 0, Warning = "当前物料未配置来料检验规范(可能为免检料)。" }; } foreach (var c in all) c.NormalizedEffective = NormalizeDate(c.EffectiveDateRaw); // ── ① MANUAL 优先:人工绑定既然存在,AUTO 不得覆盖它 ── var manual = all.Where(c => c.IsManual).ToList(); if (manual.Count > 0) { var manualSpecIds = manual.Select(c => c.SpecId).Distinct().ToList(); if (manualSpecIds.Count == 1) { var win = manual[0]; return Hit(win, S5IqcSpecResolveStatus.MatchedManual, manual.Count); } return new S5IqcSpecResolveResult { Status = S5IqcSpecResolveStatus.Ambiguous, CandidateCount = manualSpecIds.Count, Warning = $"该物料存在 {manualSpecIds.Count} 条互斥的人工绑定检规,请在 S0 订正后重试;本单未固化检规。" }; } // ── ② AUTO EXACT ── var auto = all.Where(c => !c.IsManual).ToList(); var distinctSpecIds = auto.Select(c => c.SpecId).Distinct().ToList(); if (distinctSpecIds.Count == 0) { return new S5IqcSpecResolveResult { Status = S5IqcSpecResolveStatus.NoSpec, CandidateCount = 0, Warning = "当前物料未配置来料检验规范。" }; } // 单候选:即使 sxrq 为空/不可解析也允许固化(B-2 §七 Case 1) if (distinctSpecIds.Count == 1) { var only = auto[0]; var status = only.NormalizedEffective == null ? S5IqcSpecResolveStatus.MatchedUndated : (only.NormalizedEffective.Value.Date <= referenceTime.Date ? S5IqcSpecResolveStatus.Matched // 单候选且生效日期在未来:不把未来检规当作当前检规 : S5IqcSpecResolveStatus.NoEffectiveSpec); if (status == S5IqcSpecResolveStatus.NoEffectiveSpec) { return new S5IqcSpecResolveResult { Status = status, CandidateCount = 1, Warning = $"该物料唯一检规 {only.SpecNo}/{only.SpecVersion} 生效日期 {only.EffectiveDateRaw} 晚于当前时点,本单未固化检规。" }; } return Hit(only, status, 1); } // ── ③ 多候选:先看是否跨不同 wjbh(检规编号) ── // 跨 wjbh = 两份不同的检规文件同时匹配该物料,无业务依据择一 → AMBIGUOUS(核心防误配门) var wjbhGroups = auto .GroupBy(c => (c.SpecNo ?? string.Empty).Trim(), StringComparer.OrdinalIgnoreCase) .ToList(); if (wjbhGroups.Count > 1) { return new S5IqcSpecResolveResult { Status = S5IqcSpecResolveStatus.Ambiguous, CandidateCount = distinctSpecIds.Count, Warning = $"该物料同时匹配 {wjbhGroups.Count} 个不同检规编号({string.Join(" / ", wjbhGroups.Select(g => g.Key))})," + "无法自动判定,请在 S0 订正检规物料范围或人工绑定唯一检规;本单未固化检规。" }; } // ── ④ 同一 wjbh 的多版本:按生效日期裁决(A > B > C) ── var dated = auto.Where(c => c.NormalizedEffective != null).ToList(); var effective = dated.Where(c => c.NormalizedEffective!.Value.Date <= referenceTime.Date).ToList(); var undated = auto.Where(c => c.NormalizedEffective == null).ToList(); // A. 已生效且日期可解析 → 取最大生效日期 if (effective.Count > 0) { var maxDate = effective.Max(c => c.NormalizedEffective!.Value.Date); var top = effective.Where(c => c.NormalizedEffective!.Value.Date == maxDate).ToList(); var topSpecIds = top.Select(c => c.SpecId).Distinct().ToList(); if (topSpecIds.Count == 1) return Hit(top[0], S5IqcSpecResolveStatus.Matched, distinctSpecIds.Count); // 同一最大生效日期仍对应多个 specId → 不再靠 id 排序决定业务结果 return new S5IqcSpecResolveResult { Status = S5IqcSpecResolveStatus.Ambiguous, CandidateCount = topSpecIds.Count, Warning = $"检规 {top[0].SpecNo} 存在 {topSpecIds.Count} 个生效日期相同({maxDate:yyyy-MM-dd})的版本,无法自动判定;本单未固化检规。" }; } // B. 无已生效版本,但有恰好 1 条未标注日期 → 允许 if (undated.Count > 0) { var undatedSpecIds = undated.Select(c => c.SpecId).Distinct().ToList(); if (undatedSpecIds.Count == 1) return Hit(undated[0], S5IqcSpecResolveStatus.MatchedUndated, distinctSpecIds.Count); return new S5IqcSpecResolveResult { Status = S5IqcSpecResolveStatus.Ambiguous, CandidateCount = undatedSpecIds.Count, Warning = $"检规 {undated[0].SpecNo} 有 {undatedSpecIds.Count} 个版本均未标注生效日期,无业务依据判断哪个有效;本单未固化检规。" }; } // C. 只剩未来版本 return new S5IqcSpecResolveResult { Status = S5IqcSpecResolveStatus.NoEffectiveSpec, CandidateCount = distinctSpecIds.Count, Warning = $"检规 {auto[0].SpecNo} 的全部版本生效日期均晚于当前时点,本单未固化检规。" }; } private static S5IqcSpecResolveResult Hit(S5IqcSpecCandidate c, string status, int candidateCount) => new() { Status = status, SpecId = c.SpecId, SpecNo = c.SpecNo, SpecVersion = c.SpecVersion, EffectiveDateRaw = c.EffectiveDateRaw, CandidateCount = candidateCount, }; /// /// 归一化 qms_jygf.sxrq 自由文本为日期。支持 2021.07.13 / 2021-07-13 / 2021/07/13 / /// 带时间后缀等;'AUTODED' 等非日期与 NULL 返回 null。 /// /// 口径与 S6ProcessSpecResolver.NormalizeDate 一致,此处**刻意复制而非跨模块引用**: /// CLAUDE.md 第九节·三要求模块间不直接依赖对方目录的服务类,S5 不应 using S6 的 Manufacturing 命名空间。 /// public static DateTime? NormalizeDate(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; var s = raw.Trim().Replace('.', '-').Replace('/', '-'); var sp = s.IndexOf(' '); if (sp > 0) s = s[..sp]; if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) return d; if (DateTime.TryParseExact(s, new[] { "yyyy-M-d", "yyyy-MM-dd" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out d)) return d; return null; } }