| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- using System.Globalization;
- namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
- /// <summary>S5 IQC 检规解析结果状态。</summary>
- public static class S5IqcSpecResolveStatus
- {
- /// <summary>唯一命中(AUTO EXACT,且生效日期可解析并已生效)。</summary>
- public const string Matched = "MATCHED";
- /// <summary>唯一命中人工绑定(MANUAL 优先于 AUTO)。</summary>
- public const string MatchedManual = "MATCHED_MANUAL";
- /// <summary>唯一命中,但生效日期为空/不可解析。仍固化,因 S5 历史 sxrq 未治理。</summary>
- public const string MatchedUndated = "MATCHED_UNDATED";
- /// <summary>该物料无任何检规映射。合法业务状态(如免检料),不阻断生成。</summary>
- public const string NoSpec = "NO_SPEC";
- /// <summary>有候选,但全部生效日期晚于参考时点(仅未来版本)。不把未来检规当作当前检规。</summary>
- public const string NoEffectiveSpec = "NO_EFFECTIVE_SPEC";
- /// <summary>多个互斥候选,无业务依据择一。不瞎选、不阻断,四列留空。</summary>
- public const string Ambiguous = "AMBIGUOUS";
- }
- /// <summary>Resolver 候选行(来自桥表 ⋈ qms_jygf,均已按租户双重过滤)。</summary>
- public class S5IqcSpecCandidate
- {
- public long SpecId { get; set; }
- /// <summary>检规文件编号 wjbh。</summary>
- public string? SpecNo { get; set; }
- /// <summary>检规版本 bb。</summary>
- public string? SpecVersion { get; set; }
- /// <summary>生效日期原始文本 sxrq(varchar,可能为 NULL / 'AUTODED' 等不可解析值)。</summary>
- public string? EffectiveDateRaw { get; set; }
- /// <summary>是否人工绑定行。</summary>
- public bool IsManual { get; set; }
- /// <summary>归一化后的生效日期;不可解析为 null。由 Decide 填充。</summary>
- public DateTime? NormalizedEffective { get; set; }
- }
- /// <summary>Resolver 返回模型(最小集,YAGNI:不返回完整候选列表)。</summary>
- 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; }
- /// <summary>生效日期**原始文本**,原样固化进 jgsxrq(不写归一化值,避免信息损失)。</summary>
- public string? EffectiveDateRaw { get; set; }
- public int CandidateCount { get; set; }
- public string? Warning { get; set; }
- /// <summary>是否命中可固化的检规(三个 MATCHED* 状态之一)。</summary>
- public bool IsMatched =>
- Status is S5IqcSpecResolveStatus.Matched
- or S5IqcSpecResolveStatus.MatchedManual
- or S5IqcSpecResolveStatus.MatchedUndated;
- }
- /// <summary>
- /// S5 IQC 原材料检规解析器(B-2)。
- ///
- /// 输入 (tenantId, materialCode, referenceTime) → 唯一可固化检规 或 明确的不可解析原因。
- ///
- /// 【硬边界】
- /// ① 候选唯一入口是桥表 <c>ado_s5_iqc_spec_material_map</c>,**绝不在运行期解析 qms_jygf.wlbm**;
- /// ② 物料等值匹配,禁 LIKE;
- /// ③ 租户双重过滤:<c>map.tenant_id</c> 与 <c>spec.tenant_id</c> 同时约束,不得只信桥表 spec_id;
- /// ④ 禁止无排序 FirstOrDefault、禁止用 specId 大小或版本字符串字典序决定业务结果;
- /// ⑤ **Resolver 只读,且只允许在 GenerateInspBill 调用**——detail / list / flow 路径实时 resolve
- /// 会把"今天的检规"显示到历史检验单上,破坏快照语义(B-2 §17)。
- ///
- /// 【与 S6 的差异(刻意)】S6 <c>S6ProcessSpecResolver</c> 要求 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。
- /// </summary>
- public class S5IqcSpecResolver : ITransient
- {
- private readonly ISqlSugarClient _db;
- public S5IqcSpecResolver(ISqlSugarClient db) => _db = db;
- /// <summary>
- /// 解析物料对应的唯一可固化检规。
- /// </summary>
- /// <param name="tenantId">严格可信租户(调用方已解析)。</param>
- /// <param name="materialCode">报检分录 FMATERIALCFG。</param>
- /// <param name="referenceTime">
- /// 参考时点。口径对齐 S6(<c>S6ProcessInspectionService.cs:66</c>「ReferenceDate 取检验时点(now)」):
- /// 取**检验单生成时刻**,即"生成时点当前有效"的检规版本。
- /// </param>
- public async Task<S5IqcSpecResolveResult> 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<S5IqcSpecCandidate>(
- """
- 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<SugarParameter>
- {
- new("@TenantId", tenantId),
- new("@MaterialCode", normalized),
- });
- return Decide(candidates, referenceTime);
- }
- /// <summary>
- /// 纯裁决逻辑(无 DB,便于单测)。对齐 S6 <c>S6ProcessSpecResolver.Decide</c> 的可测形态。
- /// </summary>
- public static S5IqcSpecResolveResult Decide(IReadOnlyList<S5IqcSpecCandidate>? candidates, DateTime referenceTime)
- {
- var all = (candidates ?? Array.Empty<S5IqcSpecCandidate>()).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,
- };
- /// <summary>
- /// 归一化 qms_jygf.sxrq 自由文本为日期。支持 2021.07.13 / 2021-07-13 / 2021/07/13 /
- /// 带时间后缀等;'AUTODED' 等非日期与 NULL 返回 null。
- ///
- /// 口径与 <c>S6ProcessSpecResolver.NormalizeDate</c> 一致,此处**刻意复制而非跨模块引用**:
- /// CLAUDE.md 第九节·三要求模块间不直接依赖对方目录的服务类,S5 不应 using S6 的 Manufacturing 命名空间。
- /// </summary>
- 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;
- }
- }
|